Compare commits
272 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 453da684d7 | |||
| fb6c254d38 | |||
| a71413ce67 | |||
| d66338a1c5 | |||
| 643f43ce6a | |||
| c25cf39d19 | |||
| 7d18c93339 | |||
| b1d89810ca | |||
| bb5540b62e | |||
| 80cf23e710 | |||
| ac48557535 | |||
| b4af900c10 | |||
| f132cd6468 | |||
| 75e209eb3b | |||
| 1dd94a3cc6 | |||
| d172a5e46a | |||
| 3a1b6ff355 | |||
| 68383f34de | |||
| 549fca239d | |||
| 883244936c | |||
| a8ae2e9d5e | |||
| f18582106b | |||
| d8bf6abc2b | |||
| 4bf940c579 | |||
| 4fd3bee986 | |||
| 6f6130fd12 | |||
| 3122e2f32d | |||
| e4a5ecb4be | |||
| 826062aa17 | |||
| 08f2a8f3e9 | |||
| 12d350a292 | |||
| 4fcff5f453 | |||
| 247a12bc50 | |||
| 119b42c810 | |||
| 0f9ecf12ed | |||
| 7bf2f0c687 | |||
| 8aba43da38 | |||
| 877c497325 | |||
| 08a8c25542 | |||
| ea27f17f5c | |||
| ae3408a096 | |||
| deb9aa5c69 | |||
| 2736ddfbf2 | |||
| 7889ebddfb | |||
| 4a5316b7fc | |||
| e1e94eeec7 | |||
| 176e9a5d90 | |||
| 23c1976ef1 | |||
| aab79d9508 | |||
| ecd5d5d460 | |||
| b1c278b38b | |||
| eb5a82f1f3 | |||
| b30c9f167d | |||
| 5925066d35 | |||
| 0cca633a67 | |||
| a80273433e | |||
| 320d0c5f43 | |||
| 5098b56655 | |||
| 47944d22d1 | |||
| 50199eb0af | |||
| 34cbc0d63b | |||
| 5e12b2d360 | |||
| 8dfaf44d1d | |||
| 64be8b4df5 | |||
| e585decdb2 | |||
| 6440a500f7 | |||
| d898cb193d | |||
| 557e006a4a | |||
| 3fd83b6b2b | |||
| 0fbce5d141 | |||
| ac4ce242a2 | |||
| fa6e876ab5 | |||
| c5275df1fc | |||
| f404168900 | |||
| 729c6fb5d5 | |||
| d314a269d5 | |||
| 2c3eb3308b | |||
| 6bc625b68f | |||
| b915dd776a | |||
| 9a64a65b6c | |||
| 6158e9db3b | |||
| 0d149e51f0 | |||
| bd51b9c3f2 | |||
| d05426d8a2 | |||
| 3f0efcb4ff | |||
| 19f5c2a2d5 | |||
| 455a4a355d | |||
| 8ab1568352 | |||
| 8a99e61070 | |||
| de7513d488 | |||
| 26771dc03e | |||
| fbaaaf18cd | |||
| c9574ef3c1 | |||
| 5959537d1a | |||
| 0369111d30 | |||
| 2fedb0307f | |||
| 087050fa25 | |||
| 1ac1f8bc9e | |||
| 4dc3e70877 | |||
| f64497cb13 | |||
| 68cf5c489b | |||
| e50fee2ca5 | |||
| eec214944c | |||
| 4f2c113237 | |||
| 2d93a5c703 | |||
| 2f6c1eca00 | |||
| 8642337966 | |||
| 53ce6c63d8 | |||
| 2f0900535d | |||
| 4748313105 | |||
| e2634c8815 | |||
| 424c89780e | |||
| 92309ddcc3 | |||
| 7ff204bf3d | |||
| b3ec860fac | |||
| 435dea102f | |||
| ea46001f78 | |||
| a0e5693d07 | |||
| 5c5f2af6cc | |||
| 8253993005 | |||
| d0848290da | |||
| e94a0642ff | |||
| 571d063675 | |||
| d0a605afbb | |||
| 7a83704f43 | |||
| ac5bf29ddc | |||
| cce8356b8e | |||
| b746dcc609 | |||
| 9c32b8d3b5 | |||
| c73daf891d | |||
| ae038a5165 | |||
| 367bbaaa1b | |||
| fafc28421f | |||
| 1a673753b9 | |||
| 988e49bb09 | |||
| 5bb4f364b7 | |||
| af84107905 | |||
| f94dd9b961 | |||
| 146e7e56d9 | |||
| d2e20a304a | |||
| 8d40983271 | |||
| 25cf7ce05c | |||
| 57edb8ec19 | |||
| 42663cf07c | |||
| 085b57eeb6 | |||
| dcf0379e02 | |||
| 8ecfedc91b | |||
| f7624ae92a | |||
| a035df9845 | |||
| e6598810e0 | |||
| f8cbb2ed87 | |||
| af80ccb90c | |||
| 0be7cdb00e | |||
| e01aeca761 | |||
| d4133f23f5 | |||
| 5af71a7533 | |||
| 41b5dff69b | |||
| ecfb78d84d | |||
| 041c4cff02 | |||
| 23f22d8982 | |||
| 0023617a07 | |||
| 33a51606da | |||
| 268fd03579 | |||
| 9d4d2e2a17 | |||
| 36ff9170b7 | |||
| 9e75496664 | |||
| 80d8c1dc82 | |||
| acd9e9a9fa | |||
| 89a9684f0d | |||
| 9a7027a04c | |||
| dfeaf90320 | |||
| d03e56398b | |||
| 628fc53d2c | |||
| 5fec16a768 | |||
| f6b02dec03 | |||
| 7b04dc6a50 | |||
| 12fdf0d331 | |||
| b202dd187f | |||
| 0314e587b6 | |||
| 9d7635d1e4 | |||
| 298e70b552 | |||
| 64c51647f0 | |||
| bc6b977585 | |||
| d06aae5461 | |||
| 4f941f8ba4 | |||
| 9a49833339 | |||
| 749cf6868c | |||
| a5f0b92ee9 | |||
| 6ec23b2de1 | |||
| 3298eb916c | |||
| 40430e787c | |||
| f434fa159f | |||
| 8ddf73ede3 | |||
| 844a15a3cb | |||
| 58f4c39d6f | |||
| 8a54cecd1d | |||
| 0e430e8077 | |||
| 26caa6024e | |||
| 8691a93ebf | |||
| c563f80934 | |||
| 27797d2392 | |||
| 6a4e57e5d3 | |||
| 29f94d89e1 | |||
| 12f6045da0 | |||
| 6eb80b06e3 | |||
| 86feb6d755 | |||
| 8bf49f6ea6 | |||
| f3d0fe6411 | |||
| cba3a0efb0 | |||
| b58cd75c98 | |||
| c530a989df | |||
| 2baf14d9a5 | |||
| 6903c9e51a | |||
| 31e10af9cb | |||
| 0d841d9d73 | |||
| 4bcefbc9d4 | |||
| 91db99b967 | |||
| 5747ec49c6 | |||
| d3a714a1cb | |||
| 7f8ad81065 | |||
| 75da54150f | |||
| 38fb315415 | |||
| 1c79ec3aa1 | |||
| 01e1b2fc2d | |||
| 15943e233c | |||
| f6a6b0184b | |||
| 898035d83a | |||
| de3666aed3 | |||
| c00811da2e | |||
| 55abcf8340 | |||
| 9d7c79cf0b | |||
| 24131617c7 | |||
| 05b67cbfcf | |||
| e433988962 | |||
| 05a28d1178 | |||
| 1e5bffc0e6 | |||
| f5a689b578 | |||
| e4abff4164 | |||
| 1a8f1cc3a5 | |||
| 9b99463187 | |||
| f69fa24b40 | |||
| 77d97296ea | |||
| 032679bf34 | |||
| 55a59de6db | |||
| a0d13962bd | |||
| 5f33e1e90c | |||
| b750b0fec6 | |||
| b498e5bee5 | |||
| ba577429e6 | |||
| 014b99504b | |||
| 263dd2f1bd | |||
| af43d6f438 | |||
| a4cbd6390e | |||
| 5ed0f55970 | |||
| 91ab37a39d | |||
| fbe9ae6d8b | |||
| 1aa121df71 | |||
| 6e40c166d6 | |||
| a56d4691ff | |||
| 2718fbb8fe | |||
| 2d52421919 | |||
| 167fcd475f | |||
| aa93aeda3f | |||
| 94381a67fd | |||
| c26b34147b | |||
| c9f9c98fbe | |||
| f6bf4454b0 | |||
| a7ca2c59a2 | |||
| 10c14aa794 | |||
| e9774b252b | |||
| 19fe621df1 | |||
| 82b289f4fe |
46
README.md
46
README.md
@@ -9,7 +9,7 @@ ConfigKit's simple `Load` accepts one strict YAML source (or explicit
|
|||||||
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
||||||
source groups with an independently selected main input. Sources can be host
|
source groups with an independently selected main input. Sources can be host
|
||||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||||
Enabled path and glob imports compose strictly within their group; precedence
|
Enabled explicit imports compose strictly within their group; precedence
|
||||||
between groups combines mappings recursively. Both entry points return a complete,
|
between groups combines mappings recursively. Both entry points return a complete,
|
||||||
validated configuration with detached metadata and safe diagnostics.
|
validated configuration with detached metadata and safe diagnostics.
|
||||||
Applications may perform audited defaulting and normalization through a resolver,
|
Applications may perform audited defaulting and normalization through a resolver,
|
||||||
@@ -17,7 +17,8 @@ then report domain errors and warnings through read-only semantic validation. Se
|
|||||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||||
references resolve environment names before validation, and explicit
|
references resolve environment names before validation, and explicit
|
||||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||||
CLI override records apply above overlays. Value explanation is available through
|
CLI override records apply after inheritance and typed decoding, before application
|
||||||
|
resolution. Value explanation is available through
|
||||||
detached structured records and deterministic redacted output. Built-in
|
detached structured records and deterministic redacted output. Built-in
|
||||||
`extends` relationships let a mapping inherit from a named sibling during the
|
`extends` relationships let a mapping inherit from a named sibling during the
|
||||||
same load, retaining explicit presence and source/derivation history. The root
|
same load, retaining explicit presence and source/derivation history. The root
|
||||||
@@ -26,12 +27,28 @@ the [configuration contract](docs/config.md#effective-configuration-diffs) and
|
|||||||
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
||||||
its safe changed-region records and maintained workflows.
|
its safe changed-region records and maintained workflows.
|
||||||
|
|
||||||
Sources can also be explicitly placed as sequence items, and definitions can
|
Sources can also be explicitly placed as sequence items. Configuration
|
||||||
register selected sequences to append records with distinct string IDs during
|
declarations and application registrations can give selected sequences scalar
|
||||||
strict composition. Higher-priority sequences still replace lower-priority
|
identities and choose how they combine across precedence and inheritance.
|
||||||
sequences as whole values. See the [sequence contract](docs/config.md#strict-document-composition),
|
Strict peers combine distinct records, while higher-priority collections can
|
||||||
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
|
replace, append, or upsert whole records. See the
|
||||||
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
|
[sequence contract](docs/config.md#strict-document-composition),
|
||||||
|
[maintained catalog examples](docs/consumers/README.md#identified-prompt-sequences),
|
||||||
|
and [ADR 0017](docs/adr/0017-collection-sequence-policy.md).
|
||||||
|
|
||||||
|
Imports are also explicit: a source may name files, search declared directories,
|
||||||
|
or select files with path globs. Applications can set load-wide import defaults
|
||||||
|
while keeping declaration-specific exceptions. Files are acquired in authored
|
||||||
|
declaration order; dependency-ready composition uses a shared stable scheduler.
|
||||||
|
ConfigKit does not search conventional application paths implicitly. See the
|
||||||
|
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
|
||||||
|
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||||
|
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
||||||
|
|
||||||
|
Main-file discovery is explicit too: applications choose and order `Search`
|
||||||
|
descriptions and pass `SearchConfig` to the existing load APIs. See the
|
||||||
|
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
|
||||||
|
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -67,12 +84,13 @@ mappings combine, while overlapping non-mapping definitions are errors. Between
|
|||||||
precedence tiers, mappings combine recursively, retaining omitted lower-priority
|
precedence tiers, mappings combine recursively, retaining omitted lower-priority
|
||||||
children; other values replace lower-priority values. The main document and its
|
children; other values replace lower-priority values. The main document and its
|
||||||
imports form one strictly composed tier. Explicit underlay groups sit below it
|
imports form one strictly composed tier. Explicit underlay groups sit below it
|
||||||
and above typed defaults; a selected overlay and explicit CLI overrides sit
|
and above typed defaults; a selected overlay applies before inheritance, while
|
||||||
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
explicit CLI overrides apply after decoding and before application resolution.
|
||||||
owns the detailed combination rules and input-specific constraints. At
|
The [configuration contract](docs/config.md#strict-document-composition) owns
|
||||||
definition-registered paths only, strict composition can append records with
|
the detailed combination rules and input-specific constraints. Declared or
|
||||||
distinct explicit IDs; ordinary and unregistered sequences keep their conflict
|
registered collections combine distinct identified records within strict tiers
|
||||||
or whole-value replacement behavior.
|
and use their selected operation at precedence boundaries; other sequences keep
|
||||||
|
their strict-conflict and whole-value-replacement behavior.
|
||||||
|
|
||||||
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
||||||
these priorities and how genuine new needs can justify extending the shared
|
these priorities and how genuine new needs can justify extending the shared
|
||||||
|
|||||||
@@ -43,12 +43,52 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
return SourceInfo{
|
||||||
|
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||||
|
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||||
|
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||||
|
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||||
|
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
|
||||||
|
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||||
result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
if values == nil {
|
||||||
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]ImportSelection, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
result[index] = ImportSelection{
|
||||||
|
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||||
|
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toEngineOverrides(values []Override) []engine.Override {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]engine.Override, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
result[index] = engine.Override{
|
||||||
|
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||||
|
return engine.ImportDefaults{
|
||||||
|
Required: value.Required,
|
||||||
|
Follow: value.Follow,
|
||||||
|
Hidden: value.Hidden,
|
||||||
|
Recursive: value.Recursive,
|
||||||
|
Pattern: value.Pattern,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -59,16 +99,6 @@ func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersi
|
|||||||
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &engine.SourceVersionCheck{
|
|
||||||
Path: toEnginePath(value.Path),
|
|
||||||
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
|
||||||
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func fromEngineOrigin(value engine.Origin) Origin {
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
if value.Inheritance != nil {
|
if value.Inheritance != nil {
|
||||||
@@ -82,19 +112,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func toEngineOrigin(value Origin) engine.Origin {
|
|
||||||
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
|
||||||
if value.Inheritance != nil {
|
|
||||||
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
|
|
||||||
for index, step := range value.Inheritance {
|
|
||||||
result.Inheritance[index] = engine.InheritanceStep{
|
|
||||||
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
|
|
||||||
Locations: toEngineLocations(step.Locations),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||||
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||||
}
|
}
|
||||||
|
|||||||
90
discovery.go
Normal file
90
discovery.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
|
||||||
|
// Search is an immutable ordered description of possible host-file inputs.
|
||||||
|
// It is consumed only when passed to SearchConfig and used by a load.
|
||||||
|
type Search struct{ value engine.Search }
|
||||||
|
|
||||||
|
// FilenameGroup selects filenames in order for a directory search.
|
||||||
|
// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames.
|
||||||
|
type FilenameGroup struct{ value engine.FilenameGroup }
|
||||||
|
|
||||||
|
// SearchOptions controls whether a search must select a file.
|
||||||
|
type SearchOptions struct {
|
||||||
|
// Required reports a missing-source error if the complete search is exhausted.
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectorySearchOptions selects the filename groups used by SearchDirectory.
|
||||||
|
type DirectorySearchOptions struct {
|
||||||
|
// Filenames concatenates groups in order. A nil slice uses Named then Generic;
|
||||||
|
// a non-nil empty slice selects no filenames.
|
||||||
|
Filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||||
|
func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} }
|
||||||
|
|
||||||
|
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||||
|
func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} }
|
||||||
|
|
||||||
|
// CustomFilenames selects literal filename components in the supplied order.
|
||||||
|
// Names cannot be empty or contain a directory component.
|
||||||
|
func CustomFilenames(names ...string) FilenameGroup {
|
||||||
|
return FilenameGroup{value: engine.CustomFilenames(names...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDirectory describes a search within directory. Omitted options or a nil
|
||||||
|
// Filenames slice uses Named then Generic groups. Explicitly empty Filenames
|
||||||
|
// selects no names and does not resolve the directory base.
|
||||||
|
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||||
|
options := make([]engine.DirectorySearchOptions, len(supplied))
|
||||||
|
for index, option := range supplied {
|
||||||
|
if option.Filenames == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames))
|
||||||
|
for groupIndex, group := range option.Filenames {
|
||||||
|
options[index].Filenames[groupIndex] = group.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths describes literal host-file candidates in the supplied order.
|
||||||
|
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||||
|
|
||||||
|
// Searches concatenates descriptions in order and snapshots their entries.
|
||||||
|
func Searches(searches ...Search) Search {
|
||||||
|
values := make([]engine.Search, len(searches))
|
||||||
|
for index, search := range searches {
|
||||||
|
values[index] = search.value
|
||||||
|
}
|
||||||
|
return Search{value: engine.Searches(values...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||||
|
// Loading on an unsupported platform fails if this preset is reached.
|
||||||
|
func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} }
|
||||||
|
|
||||||
|
// UserConfigDirectory describes searches rooted at the Go user-config directory.
|
||||||
|
func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} }
|
||||||
|
|
||||||
|
// HomeDirectory describes searches rooted at the Go home directory.
|
||||||
|
func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} }
|
||||||
|
|
||||||
|
// WorkingDirectory describes application-named files in the current directory.
|
||||||
|
func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} }
|
||||||
|
|
||||||
|
// SearchConfig creates a source that selects the first matching host file when
|
||||||
|
// consumed by Load or LoadLayered. Without Required, exhausting the search uses
|
||||||
|
// the absent-input lifecycle. Description and option errors are reported while
|
||||||
|
// loading.
|
||||||
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||||
|
options := make([]engine.SearchOptions, len(supplied))
|
||||||
|
for index, option := range supplied {
|
||||||
|
options[index] = engine.SearchOptions{Required: option.Required}
|
||||||
|
}
|
||||||
|
return Source{value: engine.SearchConfig(application, search.value, options...)}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
||||||
|
|
||||||
Status: accepted
|
Status: Partially superseded by
|
||||||
|
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
|
||||||
|
ordering. The remaining ownership and safe explanation decisions remain accepted.
|
||||||
|
|
||||||
Date: 2026-09-06
|
Date: 2026-09-06
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Proposed draft for review. Completed planning and remediation records are
|
Proposed draft for review; its whole-sequence precedence replacement rule is
|
||||||
|
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
|
||||||
|
retains its proposed status. Completed planning and remediation records are
|
||||||
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||||
owns current behavior. The completed implementation plan is preserved in Git
|
sequence combination and the addition of identified-sequence sibling scopes,
|
||||||
|
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
|
||||||
|
ordering. Existing mapping-sibling scopes and the remaining ownership and
|
||||||
|
inspection decisions remain accepted.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#sibling-inheritance) owns implemented
|
||||||
|
behavior. The completed implementation plan is preserved in Git
|
||||||
history at `83c59ea`.
|
history at `83c59ea`.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted and implemented. The [configuration contract](../config.md#strict-document-composition)
|
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||||
defines current behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
identity ownership and sequence combination. Explicit source placement remains
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#strict-document-composition)
|
||||||
|
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||||
shows the maintained example. The completed roadmap, implementation plan, and
|
shows the maintained example. The completed roadmap, implementation plan, and
|
||||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||||
|
|||||||
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0015: Share Dependency-Ready Ordering Across Imports and Inheritance
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted. Implemented behavior is described in the
|
||||||
|
[configuration contract](../config.md#explicit-imports) and
|
||||||
|
[internal import overview](../internal/imports.md).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-19
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Imports and sibling inheritance both resolve relationships between configuration
|
||||||
|
contributions. Their existing resolvers use different traversal rules, making
|
||||||
|
ordering depend on which mechanism expresses a dependency. Ordering affects
|
||||||
|
sequence contributions, provenance, and diagnostics, so a consistent rule is
|
||||||
|
part of ConfigKit's configuration model.
|
||||||
|
|
||||||
|
The resolvers also serve distinct purposes. Imports strictly compose sources;
|
||||||
|
inheritance overlays a child on its resolved parent after source assembly.
|
||||||
|
Item-local contributions and nested inheritance introduce scope boundaries that
|
||||||
|
resolution must preserve.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit owns one shared dependency-ready scheduler for imports and sibling
|
||||||
|
inheritance. Both resolvers use Kahn's algorithm with a stable ordering key,
|
||||||
|
selecting the smallest currently eligible node after its dependencies have
|
||||||
|
completed. The shared scheduler owns eligibility, deterministic tie-breaking,
|
||||||
|
and dependency-cycle identification. Each resolver owns construction of its
|
||||||
|
dependency relationships and application of its domain operations.
|
||||||
|
|
||||||
|
Apply the common scheduling rule within the scopes where dependencies are
|
||||||
|
available. Complete item-local contributions before item placement and resolve
|
||||||
|
enclosing inheritance before relationships in the resulting nested scopes.
|
||||||
|
Source discovery supplies the import graph; the scheduler determines resolution
|
||||||
|
order.
|
||||||
|
|
||||||
|
Strict composition, inheritance overlay, and precedence tiers retain their
|
||||||
|
existing ownership and meanings under [ADR 0001](0001-separate-composition-and-overlay.md).
|
||||||
|
The lifecycle and scope decisions in
|
||||||
|
[ADR 0009](0009-sibling-inheritance.md) continue to govern inheritance. Import
|
||||||
|
admission retains its cycle and repeated-source rules.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Deterministic depth-first postorder.** Keeps dependency branches together,
|
||||||
|
but gives traversal branch selection an additional role in ordering.
|
||||||
|
- **Dependency rounds.** Groups nodes by readiness rounds, but delays newly
|
||||||
|
eligible nodes until the current round finishes.
|
||||||
|
- **Descending longest-path depth.** Prioritizes the furthest dependencies,
|
||||||
|
but adds distance from the root as an ordering policy.
|
||||||
|
- **Separate resolver algorithms.** Allows local specialization, but leaves
|
||||||
|
consumers and maintainers with different rules for analogous dependencies.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Both resolvers follow one deterministic ordering model and share responsibility
|
||||||
|
for maintaining that model. Resolver-specific scopes, combination semantics,
|
||||||
|
and source-admission rules remain explicit at their integration boundaries.
|
||||||
|
|
||||||
|
Independent branches may interleave as nodes become eligible. Adopting this
|
||||||
|
rule changes existing traversal-dependent sequence positions and reporting
|
||||||
|
order, requiring coordinated review of source assembly, provenance, and
|
||||||
|
inheritance behavior. The configuration contract owns the implemented ordering
|
||||||
|
details.
|
||||||
83
docs/adr/0016-independent-import-controls.md
Normal file
83
docs/adr/0016-independent-import-controls.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# ADR 0016: Separate Import Selection, Traversal, and Presence Requirements
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted. Implemented behavior is described in the
|
||||||
|
[configuration contract](../config.md#explicit-imports), the
|
||||||
|
[public API declarations](../../lifecycle.go), and the
|
||||||
|
[internal import overview](../internal/imports.md).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-19
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Consumers assemble configurations from named files and collections whose layout
|
||||||
|
varies between installations. Simple imports should require little setup, while
|
||||||
|
larger collections need control over discovery, recursion, filtering, and
|
||||||
|
required contributions. Combining those choices implicitly makes declarations
|
||||||
|
harder to interpret and pushes source-selection work into applications.
|
||||||
|
|
||||||
|
Discovery can also reach a candidate through overlapping searches or encounter
|
||||||
|
different names for one canonical source. Selection and source admission need
|
||||||
|
distinct identities to support useful search unions while preserving ConfigKit's
|
||||||
|
existing source-integrity rules.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit owns import discovery and selection within its source-loading
|
||||||
|
lifecycle. Support explicit files, directory searches, and path globs as
|
||||||
|
distinct selection forms. Keep traversal, filename filtering, and presence
|
||||||
|
requirements independently expressible. Discovery determines the selected
|
||||||
|
contributions; the shared scheduler in
|
||||||
|
[ADR 0015](0015-shared-dependency-ready-ordering.md) determines their resolution
|
||||||
|
order.
|
||||||
|
|
||||||
|
Use optional-by-default imports with explicit requiredness controls. Selected
|
||||||
|
sources remain subject to ConfigKit's validity, containment, resource, and
|
||||||
|
composition contracts. This applies the library's
|
||||||
|
[simplicity and defaults principles](../policy/architecture.md#simplicity-defaults-and-explicit-controls)
|
||||||
|
to import absence while retaining meaningful errors for invalid contributions.
|
||||||
|
|
||||||
|
Applications own their selected import defaults. Authored declaration values
|
||||||
|
take precedence over application defaults, which take precedence over library
|
||||||
|
defaults. Each declaration resolves applicable controls independently,
|
||||||
|
including declarations in imported sources. This gives applications a shared
|
||||||
|
baseline while preserving explicit choices in individual declarations.
|
||||||
|
|
||||||
|
Keep declaration-local candidate identity separate from canonical source
|
||||||
|
identity. Search unions coalesce repeated candidate paths within a source
|
||||||
|
namespace before symlink resolution; filename filtering uses the selected or
|
||||||
|
discovered name. Source admission continues to use canonical identity and the
|
||||||
|
existing repeated-source rules. Distinct aliases retain their separate
|
||||||
|
inclusion routes.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Use path globs for all discovery.** Offers one selection form, but makes
|
||||||
|
collection traversal depend on path-pattern structure and complicates
|
||||||
|
independently controlling recursion and filename filtering.
|
||||||
|
- **Offer only explicit files and directory searches.** Reduces the selector
|
||||||
|
vocabulary, but moves wildcard directory selection into repeated declarations
|
||||||
|
or application-side discovery.
|
||||||
|
- **Require every import by default.** Detects absent contributions immediately,
|
||||||
|
but requires repeated opt-outs for installations with optional components.
|
||||||
|
- **Make application settings override authored declarations.** Provides central
|
||||||
|
enforcement, but prevents declarations from expressing local exceptions.
|
||||||
|
- **Deduplicate selections globally by canonical source identity.** Coalesces
|
||||||
|
aliases and separate inclusion routes, but changes the existing repeated-source
|
||||||
|
contract and obscures independently authored contributions.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can express simple imports and more selective collection searches
|
||||||
|
through the same loading lifecycle. Applications choose defaults and validate
|
||||||
|
their required effective configuration; ConfigKit owns consistent selection,
|
||||||
|
source admission, composition, and inspection.
|
||||||
|
|
||||||
|
Optional absence can conceal misspelled selectors, so requiredness and
|
||||||
|
inspection provide explicit ways to detect and explain missing contributions.
|
||||||
|
Declaration-local deduplication requires the loader to preserve candidate
|
||||||
|
identity alongside canonical source identity. The configuration and public API
|
||||||
|
contracts own the implemented behavior.
|
||||||
143
docs/adr/0017-collection-sequence-policy.md
Normal file
143
docs/adr/0017-collection-sequence-policy.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# ADR 0017: Define Identity and Merge Rules for Each Collection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
This supersedes the decisions about who declares identity and how
|
||||||
|
sequences combine in [ADR 0014](0014-sequence-placement-and-composition.md),
|
||||||
|
and the rule that inheritance always replaces sequences in
|
||||||
|
[ADR 0009](0009-sibling-inheritance.md). It also extends that ADR's sibling
|
||||||
|
scopes to identified sequences. Existing mapping-sibling scopes and source
|
||||||
|
placement continue to apply. It supersedes the whole-sequence
|
||||||
|
precedence replacement rule in the proposed
|
||||||
|
[ADR 0007](0007-source-backed-underlays.md).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-22
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ConfigKit can load separate files as records in a sequence. Applications can
|
||||||
|
register an identity key so records with different identities combine and
|
||||||
|
duplicates fail. However, a higher configuration layer or an inheriting mapping
|
||||||
|
currently replaces the whole sequence. Applications that need to extend a
|
||||||
|
catalog across layers must write their own merging code and keep source and
|
||||||
|
history information consistent with its results.
|
||||||
|
|
||||||
|
Configuration authors need to choose how records are identified and whether a
|
||||||
|
layer adds records, updates records, or replaces the collection. Applications
|
||||||
|
also need a way to require unique identities in the final loaded configuration.
|
||||||
|
These rules must work consistently for imported records, inline sequences,
|
||||||
|
overlays, and inheritance.
|
||||||
|
|
||||||
|
Records in a sequence may also share settings. Authors need to reuse those
|
||||||
|
settings through inheritance while keeping each record's own identity.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit manages identity and merge rules for each collection during loading.
|
||||||
|
Configuration files can declare those rules for imported and inline sequences,
|
||||||
|
including sequences in selected overlays. Applications can require a particular
|
||||||
|
identity definition, and configuration declarations must agree with it. Source
|
||||||
|
placement determines where each contribution enters the configuration.
|
||||||
|
|
||||||
|
Keep the distinction between contributions at the same priority and overrides
|
||||||
|
from a higher layer, as established in
|
||||||
|
[ADR 0001](0001-separate-composition-and-overlay.md). Within one tier, records
|
||||||
|
with distinct identities combine; duplicate identities and conflicting
|
||||||
|
declarations fail. Determine the applicable rules before combining those
|
||||||
|
contributions, including any identity established in lower tiers.
|
||||||
|
|
||||||
|
Across layers, support three operations: replace the whole sequence, append
|
||||||
|
records while rejecting duplicate identities, or replace whole records by
|
||||||
|
identity (`upsert`). Source layering and sibling inheritance use the same
|
||||||
|
operations. For inheritance, the rules at the inheriting destination determine
|
||||||
|
which operation applies.
|
||||||
|
|
||||||
|
An identified sequence is also a sibling scope for inheritance. A record may
|
||||||
|
inherit from one other record in the same sequence, selected by its complete
|
||||||
|
identity. Each record supplies its own identity before inheritance. The child
|
||||||
|
keeps that identity and combines its settings with the parent's other fields,
|
||||||
|
using the existing mapping and collection rules. Parents remain ordinary records
|
||||||
|
subject to normal validation.
|
||||||
|
|
||||||
|
Resolve these relationships after source assembly, using the same dependency
|
||||||
|
ordering as mapping-sibling inheritance. Resolve nested relationships at each
|
||||||
|
inheriting destination. Preserve the assembled sequence order. Whole-record
|
||||||
|
upsert keeps its existing meaning; inheritance then uses the records that remain.
|
||||||
|
Typed defaults, CLI overrides, and application resolution follow the lifecycle
|
||||||
|
in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
|
An explicitly selected operation remains in effect at its destination until a
|
||||||
|
higher layer changes it. When no explicit operation applies, use upsert if an
|
||||||
|
identity definition is available; otherwise replace the whole sequence. Authors
|
||||||
|
can declare identity and an operation before supplying any records. Keep source
|
||||||
|
acquisition order separate from layer priority and the dependency ordering
|
||||||
|
established by [ADR 0015](0015-shared-dependency-ready-ordering.md).
|
||||||
|
|
||||||
|
Rules declared inside a record belong to that record. Retaining the record
|
||||||
|
retains its local rules; replacing it discards them. Replacing a whole collection
|
||||||
|
can discard its previous YAML identity definition, while application requirements
|
||||||
|
still apply. Inheritance can copy collection identity, but the inheriting
|
||||||
|
destination chooses its operation. Application registrations continue to govern
|
||||||
|
the paths where they were registered. The same ownership rules apply to
|
||||||
|
declarations made before a sequence exists.
|
||||||
|
|
||||||
|
Check YAML-declared identity when combining collections during source assembly
|
||||||
|
and inheritance. Application registrations also require the final loaded
|
||||||
|
sequences to satisfy their identity rules, including after typed defaults, CLI
|
||||||
|
overrides, and application resolution. This guarantee checks sequences that
|
||||||
|
exist; the application schema and validation decide whether a collection must
|
||||||
|
exist and which value types are allowed. The guarantee applies when loading
|
||||||
|
completes, rather than to every intermediate callback state or changes made by
|
||||||
|
the caller afterward.
|
||||||
|
|
||||||
|
Preserve explicit presence, source attribution, history, sensitivity, and
|
||||||
|
resource context through these operations, following
|
||||||
|
[ADR 0003](0003-preserve-presence-and-resolution-history.md). Typed defaults,
|
||||||
|
CLI assignments, and application resolution keep their own rules for changing
|
||||||
|
values. The public API provides registration and inspection; the implementation
|
||||||
|
chooses how to store and track collection rules internally.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep identity application-owned and always replace sequences across layers.**
|
||||||
|
Keeps the current design small, but reusable catalogs require application
|
||||||
|
changes or custom merging code.
|
||||||
|
- **Put merge rules on each import.** Keeps the rules beside the imported source,
|
||||||
|
but inline sequences and overlays need a separate way to declare them. It also
|
||||||
|
makes rules for a collection spread across several imports harder to reconcile.
|
||||||
|
- **Merge individual fields of records with matching identities.** Allows partial
|
||||||
|
record updates, but adds rules for field conflicts and retained values.
|
||||||
|
Replacing the whole record makes the update's scope clear.
|
||||||
|
- **Keep inheritance limited to mapping siblings.** Keeps parent lookup simpler,
|
||||||
|
but authors of sequence records must duplicate shared settings or applications
|
||||||
|
must implement inheritance themselves. Collection identity provides a way to
|
||||||
|
select a parent within the existing loading process.
|
||||||
|
- **Check every YAML identity again at the end of loading.** Gives YAML declarations
|
||||||
|
authority over values changed by defaults, CLI assignments, and application
|
||||||
|
resolution. This extends the lifetime of those declarations; application
|
||||||
|
registration provides that final guarantee when needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can build and customize catalogs in one load while retaining useful
|
||||||
|
inspection information. Configuration authors choose how collections combine
|
||||||
|
within application requirements. Duplicate identities among contributions at
|
||||||
|
the same priority remain errors.
|
||||||
|
|
||||||
|
Existing registered collections use upsert across layers by default unless an
|
||||||
|
explicit operation selects otherwise. Correct loading now requires tracking
|
||||||
|
which declarations still apply as records are replaced, copied, or moved to new
|
||||||
|
positions. Lower-tier identity must be available before combining higher-tier
|
||||||
|
contributions.
|
||||||
|
|
||||||
|
Record inheritance also requires parent lookup by identity and checking for
|
||||||
|
missing parents and cycles. Copied settings retain the same source, history,
|
||||||
|
and sensitivity information as mapping-sibling inheritance.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#strict-document-composition) defines
|
||||||
|
declaration syntax, identity comparison, omission and empty-value behavior, and
|
||||||
|
diagnostics for the implemented feature.
|
||||||
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# ADR 0018: Apply CLI Overrides After Inheritance and Decoding
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
This supersedes the rule that CLI overrides apply before decoding in
|
||||||
|
[ADR 0005](0005-explicit-overrides-and-safe-explanation.md) and the
|
||||||
|
rule that they apply before inheritance in [ADR 0009](0009-sibling-inheritance.md).
|
||||||
|
Their remaining ownership, inheritance scope, and safe inspection decisions
|
||||||
|
continue to apply.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-22
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Explicit command-line values are the operator's highest-priority input. If they
|
||||||
|
apply before inheritance, a sequence supplied as a replacement could gain
|
||||||
|
inherited records afterward. Its final contents would then differ from the
|
||||||
|
operator's assignment.
|
||||||
|
|
||||||
|
The application also needs these values when it fills missing settings,
|
||||||
|
normalizes values, or computes derived settings. ConfigKit needs a clear loading
|
||||||
|
order that preserves both CLI precedence and the application's role in resolving
|
||||||
|
the final configuration.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit uses this loading order:
|
||||||
|
|
||||||
|
1. Combine sources within tiers and apply precedence between layers.
|
||||||
|
2. Resolve sibling inheritance, apply typed defaults, and decode the configuration
|
||||||
|
into the application's types.
|
||||||
|
3. Apply explicit CLI overrides and check them against the application schema.
|
||||||
|
4. Run application resolution using the overridden configuration.
|
||||||
|
5. Look up effective secret references, validate the final configuration, and
|
||||||
|
return it.
|
||||||
|
|
||||||
|
CLI overrides are the highest-priority input to application resolution. A CLI
|
||||||
|
sequence replaces the whole sequence; mappings combine recursively. Collection
|
||||||
|
merge rules apply during the earlier source and inheritance steps. Inheritance
|
||||||
|
and typed defaulting do not run again after CLI overrides.
|
||||||
|
|
||||||
|
Keep track of which values were explicitly supplied and which CLI input supplied
|
||||||
|
them. Applications continue to parse command-line arguments; ConfigKit applies
|
||||||
|
and validates the overrides and makes their effects inspectable. Reject
|
||||||
|
structural directives in override paths and values, so instructions such as
|
||||||
|
inheritance relationships remain part of source processing.
|
||||||
|
|
||||||
|
Application resolution keeps its existing rules for defaults and recorded
|
||||||
|
changes under [ADR 0003](0003-preserve-presence-and-resolution-history.md).
|
||||||
|
It receives CLI values together with their presence and source information.
|
||||||
|
Defaults that only fill missing settings preserve explicit CLI values.
|
||||||
|
Application normalization and derived-value calculations can still change them,
|
||||||
|
with those changes recorded. Secret lookup and final validation use the resulting
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Apply CLI overrides before inheritance.** Preserves existing ordering, but
|
||||||
|
later merging needs special handling to preserve CLI replacement intent while
|
||||||
|
still combining ordinary source contributions.
|
||||||
|
- **Apply CLI overrides after inheritance but before typed decoding.** Allows an
|
||||||
|
override to fix a source value that would otherwise fail decoding, but leaves
|
||||||
|
typed defaulting after the operator's assignment. Completing defaulting and
|
||||||
|
decoding first gives CLI overrides an already resolved configuration to modify.
|
||||||
|
- **Apply CLI overrides after application resolution.** Applies assignments after
|
||||||
|
the application's changes, but derived settings still reflect earlier values
|
||||||
|
unless resolution runs again.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
CLI overrides change a configuration whose inheritance and typed defaults are
|
||||||
|
already resolved. The application can then derive settings from the operator's
|
||||||
|
values. The final values, source information, history, and sensitivity handling
|
||||||
|
must remain consistent throughout loading.
|
||||||
|
|
||||||
|
CLI input can no longer add or cancel inheritance relationships or bypass an
|
||||||
|
earlier error in source assembly or inheritance. It also cannot fix a value that
|
||||||
|
fails the earlier decoding step. Semantic validation still runs after overrides
|
||||||
|
and application resolution. The [configuration contract](../config.md#explicit-cli-overrides)
|
||||||
|
and [consumer guidance](../consumers/README.md) describe this behavior.
|
||||||
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-24
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications often need a small ordered convention for choosing a main
|
||||||
|
configuration file. Keeping that choice entirely in each application repeats
|
||||||
|
path selection and file-loading plumbing. Moving the convention into ConfigKit
|
||||||
|
without an explicit declaration would make ordinary loads search paths that a
|
||||||
|
consumer did not choose, obscuring both precedence and filesystem access.
|
||||||
|
|
||||||
|
ConfigKit already owns synchronous source acquisition, parsing, composition,
|
||||||
|
decoding, validation, and inspection. A selected host file can therefore enter
|
||||||
|
that lifecycle as the existing file source, preserving its source identity,
|
||||||
|
resource context, and diagnostics. Optional exhaustion can use the same absent
|
||||||
|
main lifecycle as an explicitly absent source.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Consumers choose and order the main-file conventions they want to use. They may
|
||||||
|
describe literal paths, directory filename groups, and supported directory
|
||||||
|
presets, and may mark the complete search as required. Search descriptions are
|
||||||
|
explicit inputs to loading; no preset or convention is activated implicitly.
|
||||||
|
|
||||||
|
During synchronous loading, ConfigKit resolves the description to one real host
|
||||||
|
file or to absence before parsing. The selected file follows the existing source
|
||||||
|
acquisition and processing rules. A selected-file access, parse, version,
|
||||||
|
composition, decode, or validation failure is terminal and does not resume the
|
||||||
|
search. Optional exhaustion shares the absent-main lifecycle; required
|
||||||
|
exhaustion reports a missing-source diagnostic. No candidate-attempt history is
|
||||||
|
added to public metadata.
|
||||||
|
|
||||||
|
This keeps convention choice with applications while moving repeated selection
|
||||||
|
and loading plumbing into the library's established lifecycle. It preserves
|
||||||
|
explicit control, ordinary source behavior, and complete-on-return ownership.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep all search and fallback loading in each application.** This preserves
|
||||||
|
consumer ownership but repeats the file-selection and load coordination that
|
||||||
|
ConfigKit already owns, and risks divergent handling of selected-file errors.
|
||||||
|
- **Search conventional locations implicitly during every load.** This reduces
|
||||||
|
setup, but activates filesystem access and precedence the consumer did not
|
||||||
|
select and makes ordinary source behavior less predictable.
|
||||||
|
- **Return candidate paths for the application to load one by one.** This keeps
|
||||||
|
selection outside the load lifecycle and encourages repeated complete loads,
|
||||||
|
potentially rerunning defaults, callbacks, and validation during fallback.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The public search description must be immutable and root-owned, with private
|
||||||
|
engine conversion and selection. The contract documents candidate ordering,
|
||||||
|
selection, exhaustion, context, and provenance; the API declaration documents
|
||||||
|
the public symbols. Dynamic bases and file availability are observed when each
|
||||||
|
load consumes the description. Applications remain responsible for selecting
|
||||||
|
conventions and for their schema and domain requirements.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md) owns implemented behavior, and the
|
||||||
|
[component overview](../internal/overview.md) describes implementation ownership.
|
||||||
|
The completed implementation plan and remediation record are preserved in Git
|
||||||
|
history at `0369111` under `docs/roadmap/`.
|
||||||
661
docs/config.md
661
docs/config.md
@@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors:
|
|||||||
| Constructor | Current contract |
|
| Constructor | Current contract |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
||||||
|
| `SearchConfig(application, search, options...)` | Creates an explicit, reusable search source. Loading selects one host file or follows the absent-input lifecycle when an optional search is exhausted. |
|
||||||
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
||||||
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
||||||
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
||||||
@@ -28,6 +29,79 @@ Keys are sorted for stable provenance, integer decimal spelling stays exact, and
|
|||||||
cycles or unsupported types are rejected without exposing values. Mapping input
|
cycles or unsupported types are rejected without exposing values. Mapping input
|
||||||
uses the same directive and version rules as YAML, not CLI override restrictions.
|
uses the same directive and version rules as YAML, not CLI override restrictions.
|
||||||
|
|
||||||
|
### Configuration discovery
|
||||||
|
|
||||||
|
Discovery is opt-in. Construct `Search` from literal `Paths`, directory searches,
|
||||||
|
or the supported directory presets, then pass it to `SearchConfig`. Search
|
||||||
|
descriptions are immutable and reusable; each load resolves dynamic directories
|
||||||
|
and checks current candidate files again. `Searches` is ordered concatenation.
|
||||||
|
It preserves the supplied description order and does not deduplicate candidates.
|
||||||
|
|
||||||
|
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
||||||
|
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
||||||
|
`main.yaml`. `SearchDirectory` defaults to `[Named, Generic]` when options are
|
||||||
|
omitted or `Filenames` is nil. A non-nil empty filename list contributes no
|
||||||
|
candidates and does not resolve its directory. `CustomFilenames` supplies
|
||||||
|
literal single filename components; it does not expand application-name
|
||||||
|
placeholders. An application slug is required only when a selected directory or
|
||||||
|
filename group uses it. Literal custom paths are not expanded for `~`, variables,
|
||||||
|
or wildcards; relative paths are resolved from the working directory at load time.
|
||||||
|
The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group
|
||||||
|
contribute no candidates. An explicitly supplied empty path or filename is
|
||||||
|
invalid. Slugs and custom filenames must be usable single host filename
|
||||||
|
components; ConfigKit preserves their case and spelling.
|
||||||
|
|
||||||
|
Search expansion is ordered concatenation. For each directory search, ConfigKit
|
||||||
|
tries its filename groups and names before advancing to the next directory.
|
||||||
|
When multiple directories use the same filename list, candidates follow the
|
||||||
|
directory-first Cartesian product: every filename in one directory is tried
|
||||||
|
before the next directory. Explicit file candidates stay at their declared
|
||||||
|
positions among directory searches.
|
||||||
|
|
||||||
|
The presets expand to these ordered directories and filename groups:
|
||||||
|
|
||||||
|
| Preset | Directory, in order | Filename groups |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `UnixSystemDirectories` | `/usr/local/etc/<application>`, `/etc/<application>` | Named, then Generic |
|
||||||
|
| `UnixSystemDirectories` | `/etc` | Named only |
|
||||||
|
| `UserConfigDirectory` | Go user-config directory / `<application>` | Named, then Generic |
|
||||||
|
| `UserConfigDirectory` | Go user-config directory | Named only |
|
||||||
|
| `HomeDirectory` | Go home directory / `.<application>` | Named, then Generic |
|
||||||
|
| `HomeDirectory` | Go home directory | Named only |
|
||||||
|
| `WorkingDirectory` | Current working directory | Named only |
|
||||||
|
|
||||||
|
The Unix system preset is supported on aix, android, darwin, dragonfly, freebsd,
|
||||||
|
hurd, illumos, ios, linux, netbsd, openbsd, and solaris; reaching it on another
|
||||||
|
platform is an option error. Other presets use Go's user-config, home, and
|
||||||
|
working-directory helpers. A reached
|
||||||
|
directory-helper error terminates selection. Bases for later searches are not
|
||||||
|
resolved after an earlier candidate is selected. Missing candidates continue;
|
||||||
|
the first existing candidate is selected and then follows ordinary file loading
|
||||||
|
rules. Errors while acquiring or processing a selected file are terminal and do
|
||||||
|
not resume the search.
|
||||||
|
|
||||||
|
Searches are optional by default. Exhaustion uses the same absent-main lifecycle
|
||||||
|
as `NoSource()`: there is no main document, source record, source ID, or explicit
|
||||||
|
presence contribution. Imports may be enabled but there are no main declarations
|
||||||
|
to traverse. Defaults, version checks, overlays, CLI overrides, resolution, and
|
||||||
|
validation retain their normal lifecycle. `SearchOptions{Required: true}` makes
|
||||||
|
exhaustion an error; it does not change candidate or selected-file error handling.
|
||||||
|
|
||||||
|
For an ordinary active overlay after optional exhaustion, provide an explicit
|
||||||
|
filesystem context. A host context with only `Root` is sufficient; ConfigKit
|
||||||
|
loads the overlay at its normal root-relative location and does not infer a root
|
||||||
|
from attempted search directories. The same root-only context can accompany a
|
||||||
|
search that selects a file: the selected file determines its base and must be
|
||||||
|
inside `Root`. A selected host file rejects an explicit `BaseDir` or native
|
||||||
|
filesystem context. Invalid context fails without resuming selection. Layered
|
||||||
|
overlays keep their independent `OverlayFilesystem` requirement.
|
||||||
|
|
||||||
|
When a search selects a file, inspection reports that actual file with ordinary
|
||||||
|
source IDs, paths, placement, metadata origins, and resource context. Missing
|
||||||
|
candidates create no records. `SearchConfig` can be used as the main source of
|
||||||
|
ordinary or layered loading, including an underlay input, and does not add a new
|
||||||
|
precedence tier.
|
||||||
|
|
||||||
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||||
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||||
@@ -52,10 +126,11 @@ preserves already validated secret state without rereading the environment.
|
|||||||
The current lifecycle is:
|
The current lifecycle is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
validate options → parse the selected source → traverse/compose enabled imports
|
validate options → parse the selected source → extract source controls and
|
||||||
|
traverse/strictly compose enabled imports
|
||||||
→ build schema → check the optional application version → apply a selected overlay
|
→ build schema → check the optional application version → apply a selected overlay
|
||||||
→ apply explicit CLI overrides → expand sibling inheritance
|
→ expand sibling inheritance
|
||||||
→ create and clone defaults → strictly decode
|
→ create and clone defaults → strictly decode → apply explicit CLI overrides
|
||||||
→ recorded application resolution
|
→ recorded application resolution
|
||||||
→ resolve typed secret references → semantic validation → classify secrets
|
→ resolve typed secret references → semantic validation → classify secrets
|
||||||
→ snapshot configuration and metadata
|
→ snapshot configuration and metadata
|
||||||
@@ -68,17 +143,23 @@ contributes no source, and continues through defaults, decode, and projection. A
|
|||||||
missing file remains an error; it is never interpreted as no input.
|
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 path and glob 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
|
||||||
CLI coordinates remain rooted at the application root. `NoSource` rejects
|
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
||||||
imports, active overlays, and filesystem context as invalid combinations but
|
source-root collection declarations whether or not imports are enabled; an
|
||||||
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
|
`imports` declaration still requires imports to be enabled. `NoSource` may enable
|
||||||
while imports are disabled.
|
imports, but it has no main declarations to traverse. It accepts an explicit
|
||||||
|
filesystem context: a host context requires an absolute existing `Root` and may
|
||||||
|
omit `BaseDir` to use that root as its internal import base; a native context
|
||||||
|
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
|
||||||
|
when imports are disabled. An active ordinary overlay requires this explicit
|
||||||
|
context. `NoSource` accepts overrides and remains absent rather than contributing
|
||||||
|
an empty document.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||||
@@ -128,26 +209,34 @@ nothing. Underlay groups are listed from lowest to highest priority. Repeating
|
|||||||
a file within one group remains an import/repetition error; the same reusable
|
a file within one group remains an import/repetition error; the same reusable
|
||||||
source may be selected again in another tier.
|
source may be selected again in another tier.
|
||||||
|
|
||||||
ConfigKit acquires and composes the main input first, constructs the schema,
|
ConfigKit acquires the main input first and constructs the schema. It then
|
||||||
and checks the optional application version from the main tier only. Underlay
|
acquires and strictly composes underlay groups from lowest to highest priority,
|
||||||
roots cannot claim that version path (`underlay.version`); imported fragments
|
passing each group's accumulated lower collection policy into the next group.
|
||||||
retain `import.version` protection. It then acquires and composes underlay
|
Each group is applied once as a precedence tier. The main tier is then composed
|
||||||
groups in order, recursively combines the groups, and applies the main tier,
|
with the accumulated underlay policy, and the optional application version is
|
||||||
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
|
checked against that composed main tier only. Underlay roots cannot claim that
|
||||||
definitions replace lower values between tiers; mappings combine recursively.
|
version path (`underlay.version`); imported fragments retain `import.version`
|
||||||
Syntax and strict-composition errors inside a tier cannot be masked by a
|
protection. ConfigKit then applies the selected overlay and resolves inheritance.
|
||||||
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
|
Scalar, sequence, and null definitions replace lower values between tiers unless
|
||||||
validation, and publication run once on the final assembled input.
|
a collection policy defines the sequence operation; mappings combine recursively.
|
||||||
|
After successful defaults and typed decoding, CLI overrides are applied to that
|
||||||
|
typed configuration before application resolution. Syntax, strict-composition,
|
||||||
|
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
||||||
|
secret lookup, validation, and publication run once on the resulting values.
|
||||||
|
|
||||||
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
||||||
independent of the main source's namespace. Its name and root-relative lookup
|
independent of the main source's namespace. Its name and root-relative lookup
|
||||||
follow the selected-overlay rules below; inactive overlays ignore the context.
|
follow the selected-overlay rules below; inactive overlays ignore the context.
|
||||||
The same path in a different filesystem namespace is not a repeated file.
|
The same path in a different filesystem namespace is not a repeated file.
|
||||||
Ordinary `Load` continues to infer its selected-overlay context from its source.
|
Ordinary `Load` infers overlay context from sources that provide one; with
|
||||||
|
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
||||||
|
|
||||||
Source IDs follow acquisition order—main and its depth-first imports, then each
|
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||||
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||||
history follows precedence order. Source `Layer` describes precedence while
|
history follows precedence order. Default main-version checking happens after
|
||||||
|
underlay composition because the main tier inherits the accumulated collection
|
||||||
|
policy. Enabled per-source version checks retain acquisition order and source
|
||||||
|
local headers. Source `Layer` describes precedence while
|
||||||
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
|
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
|
||||||
one-based `UnderlayIndex` giving their original tier position; all other records
|
one-based `UnderlayIndex` giving their original tier position; all other records
|
||||||
have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
||||||
@@ -213,28 +302,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 mapping sibling scope or identified sequence, direct definitions
|
||||||
|
resolve by dependency readiness. A parent is resolved before a child; among
|
||||||
|
currently eligible definitions, the lexically smallest canonical semantic
|
||||||
|
pointer is selected, including newly eligible definitions as soon as their
|
||||||
|
parent completes. This ordering governs inheritance operations and their
|
||||||
|
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, sequence, 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
|
||||||
The child retains its key. All effective relationships resolve, including
|
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
|
||||||
|
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
|
||||||
|
unkeyed destination replaces. A parent's explicit mode does not move to a
|
||||||
|
different destination. Sequences outside collection destinations remain atomic
|
||||||
|
replacements.
|
||||||
|
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.
|
||||||
@@ -416,20 +532,22 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
|
|||||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||||
value for absent input remains non-explicit.
|
value for absent input remains non-explicit.
|
||||||
|
|
||||||
Appending registered records during strict composition assigns each contribution
|
Strict composition assigns contributed records their assembled sequence positions
|
||||||
its assembled sequence index in history. A later precedence tier that supplies a
|
in history. Across tiers, history follows the selected
|
||||||
sequence records replacement at the sequence path; history does not imply
|
[collection operation](#strict-document-composition), including append and
|
||||||
identity-based merging across tiers.
|
whole-record upsert. Incoming contributions use their assigned positions, and
|
||||||
|
replaced payload remains available as historical evidence.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||||
has an overlay-layer composite origin with the lower contributing locations followed
|
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||||
by the overlay location; an empty mapping is still an explicit container even though
|
composite origin with the lower contributing locations followed by the overlay
|
||||||
it contributes no children. Resolution origins are synthetic: they have layer
|
location; an empty mapping is still an explicit container even though it contributes
|
||||||
|
no children. Resolution origins are synthetic: they have layer
|
||||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
removed. Source records and IDs retain [acquisition order](#explicit-imports),
|
||||||
selected overlay, then synthetic CLI records in supplied order.
|
including the underlay ordering described in [layered loading](#layered-loading).
|
||||||
|
|
||||||
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||||
lists concrete transfers from contributor paths through destinations, with
|
lists concrete transfers from contributor paths through destinations, with
|
||||||
@@ -492,29 +610,34 @@ historical path. Its effective value and each chronological before/after value a
|
|||||||
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
||||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||||
null is present and renders as `null`. The history starts with the canonical typed
|
null is present and renders as `null`. The history starts with the canonical typed
|
||||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
application-default or Go-zero baseline, followed by relevant underlay and main-tier
|
||||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
contributions in precedence order, the selected overlay, and structural inheritance
|
||||||
resolution operations. Structural inheritance events appear after source replay
|
events. Typed defaulting and decoding reconcile that source history before supplied
|
||||||
and before typed reconciliation, including consumed or canceled `extends` input
|
CLI records and audited net resolution operations. Within each strict tier, replay follows
|
||||||
and equal-valued child replacements. Overlapping parent/child resolution records
|
[scoped assembly order](#explicit-imports), not source-record acquisition order.
|
||||||
|
Inheritance events include consumed or canceled `extends` input and equal-valued
|
||||||
|
child replacements. Overlapping parent/child resolution records
|
||||||
are rendered
|
are rendered
|
||||||
as one net subtree event with combined reasons and dependencies, rather than
|
as one net subtree event with combined reasons and dependencies, rather than
|
||||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||||
writes remain visible. When input replay
|
writes remain visible. When structural replay differs from the pre-CLI typed input,
|
||||||
differs from the final typed input, a `ChangeDecoding` event records the transition
|
a `ChangeDecoding` event records retained defaults and canonical decoding. CLI
|
||||||
before domain resolution. Its origin retains the decoded path's source/default
|
contributions then retain their raw input representation while effective and
|
||||||
attribution; its reason identifies static-default retention and canonical decoding.
|
resolution values use the typed projection. This built-in inspection operation is
|
||||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
not accepted by `Resolution.Record`.
|
||||||
|
|
||||||
Base, overlay, and CLI history preserves the policy-validated input representation,
|
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||||
including a superseded value that could not have decoded into the final Go field.
|
including a superseded value that could not have decoded into the final Go field.
|
||||||
Baseline, effective, and resolution values use canonical typed projection. A
|
Baseline, effective, and resolution values use canonical typed projection. A
|
||||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||||
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
|
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
|
||||||
queries reconstruct each source or override contribution, while sequences remain
|
queries reconstruct each source or override contribution. A sequence snapshot
|
||||||
atomic: a descendant history identifies its presence before and after the containing
|
represents the collection at that point in replay, and sequence diffs remain
|
||||||
sequence replacement without implying element-wise merging. Removed paths remain
|
atomic. Descendant queries describe their presence and values before and after
|
||||||
explainable; unknown and malformed paths return false.
|
containing sequence operations. Combination follows the
|
||||||
|
[collection operation tables](#strict-document-composition); upsert replaces
|
||||||
|
matching records in full. Removed paths remain explainable; unknown and malformed
|
||||||
|
paths return false.
|
||||||
|
|
||||||
Every snapshot applies the same historical tag, predicate, and recorded-dependency
|
Every snapshot applies the same historical tag, predicate, and recorded-dependency
|
||||||
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
|
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
|
||||||
@@ -601,13 +724,15 @@ must resolve to a nonempty value. Secret references work in typed structs, maps,
|
|||||||
pointers, slices, and arrays. A dynamic `any` value does not establish secret
|
pointers, slices, and arrays. A dynamic `any` value does not establish secret
|
||||||
semantics and cannot contain `Secret`; applications must use a typed slot.
|
semantics and cannot contain `Secret`; applications must use a typed slot.
|
||||||
|
|
||||||
ConfigKit resolves only the effective names after recorded application resolution
|
ConfigKit resolves only the effective names after CLI assignment and recorded
|
||||||
and before semantic validation. Each unique name is read with `os.LookupEnv` once
|
application resolution, and before semantic validation. CLI overrides may supply
|
||||||
per load. Validation can inspect the credential through the deliberately named
|
or replace a typed secret reference; lookup uses that effective name and retains
|
||||||
`Value()` accessor and distinguish success with `IsResolved()`. Later loads read
|
its effective source location for diagnostics. Each unique name is read with
|
||||||
the environment again, and cloned defaults are rebuilt from names so a previously
|
`os.LookupEnv` once per load. Validation can inspect the credential through the
|
||||||
resolved result cannot retain a stale credential. `Load` never writes to the
|
deliberately named `Value()` accessor and distinguish success with `IsResolved()`.
|
||||||
environment.
|
Later loads read the environment again, and cloned defaults are rebuilt from names
|
||||||
|
so a previously resolved result cannot retain a stale credential. `Load` never
|
||||||
|
writes to the environment.
|
||||||
|
|
||||||
Credential bytes are held only in the per-load lookup state and the trusted returned
|
Credential bytes are held only in the per-load lookup state and the trusted returned
|
||||||
configuration, and are returned only by `Value`. Secret formatting uses a safe
|
configuration, and are returned only by `Value`. Secret formatting uses a safe
|
||||||
@@ -671,18 +796,19 @@ files when appropriate; ConfigKit stores no implicit global report.
|
|||||||
|
|
||||||
### Recorded application resolution
|
### Recorded application resolution
|
||||||
|
|
||||||
An optional `Definition.Resolve` callback runs after successful typed decoding. It
|
An optional `Definition.Resolve` callback runs after successful defaults, typed
|
||||||
receives the working configuration and a per-load `Resolution` context. The context's
|
decoding, and explicit CLI assignment. It receives the working configuration and a
|
||||||
|
per-load `Resolution` context. The context's
|
||||||
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||||
null are never confused with absence or static defaults.
|
null are never confused with absence or static defaults.
|
||||||
|
|
||||||
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
|
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
|
||||||
decoded entry state, before application resolution runs. It includes retained static
|
post-decode entry state, including explicit CLI overrides, before application
|
||||||
defaults, preserves explicit null, and uses the same exact semantic path matching as
|
resolution runs. It includes retained static defaults, preserves explicit null, and
|
||||||
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
|
uses the same exact semantic path matching as `Metadata.Lookup`; missing,
|
||||||
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
|
malformed, and key/index-shape-mismatched paths return false without diagnostics.
|
||||||
found entry path. Resolution changes, additions, removals, and moves do not alter
|
Its `Explicit` value agrees with `IsExplicit` for every found entry path. Resolution
|
||||||
this entry view.
|
changes, additions, removals, and moves do not alter this entry view.
|
||||||
|
|
||||||
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
||||||
the same load: the main source, imports, selected overlay, and supplied CLI records
|
the same load: the main source, imports, selected overlay, and supplied CLI records
|
||||||
@@ -757,8 +883,11 @@ live services as a substitute for configuration validation.
|
|||||||
|
|
||||||
## Strict document composition
|
## Strict document composition
|
||||||
|
|
||||||
ConfigKit strictly composes parsed base documents when `Options.Imports` is true.
|
Ordinary `Load` applies strict document composition to its parsed source scope;
|
||||||
Application defaults do not participate in this operation.
|
`Options.Imports` adds imported documents as peers in that scope. Application
|
||||||
|
defaults do not participate. With imports disabled, the single source still has
|
||||||
|
its controls extracted and any declared or registered collection identity is
|
||||||
|
validated, but there are no imported peers to combine.
|
||||||
|
|
||||||
| Existing node | Incoming node | Result |
|
| Existing node | Incoming node | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -769,54 +898,147 @@ Application defaults do not participate in this operation.
|
|||||||
| 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. Nulls
|
sequence/sequence contributions at a destination with effective identity from an
|
||||||
remain atomic conflicts. Unregistered sequences conflict when repeated and are
|
application registration, a current YAML `key` declaration, or retained lower
|
||||||
never joined by index. Disjoint entries under shared mappings compose, making
|
policy. Nulls remain atomic conflicts.
|
||||||
stable keyed mappings the natural representation for independently contributed
|
Sequences without effective identity conflict when repeated and are never joined
|
||||||
entries.
|
by index. Disjoint entries under shared mappings compose, making stable keyed
|
||||||
|
mappings the natural representation for independently contributed entries.
|
||||||
|
|
||||||
`Definition.SequenceCompositions` registers literal mapping paths and one literal
|
`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. Selector segments consume one mapping level and match a
|
||||||
validated, including a singleton contribution: each record must be a mapping with
|
complete destination; an any-mapping-key segment matches exactly one key, not an
|
||||||
a present string identity field. Distinct exact identity strings append in source
|
arbitrary prefix or suffix. Each registration requires one or more literal,
|
||||||
and list order; duplicate identities conflict even if the records are otherwise
|
direct field names; compound field sets are normalized as sets, and field names
|
||||||
equal or disjoint. Empty identity strings are valid. Malformed records produce
|
are not otherwise normalized. The same field-set and scalar-equality rules apply
|
||||||
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
|
to YAML `key` declarations.
|
||||||
sequence container and source locations identifying a duplicate without exposing
|
|
||||||
identity values. Missing paths and non-sequence values do not fail by themselves.
|
|
||||||
Identical registrations coalesce; conflicting registrations at one path and
|
|
||||||
invalid registration paths produce `options.invalid`. Unused and root registrations
|
|
||||||
are allowed and have no effect.
|
|
||||||
|
|
||||||
Registration changes strict composition only. Each underlay group and the main
|
At each selected path, records must provide every effective identity field as a
|
||||||
tier has independent duplicate-identity state. Precedence assembly is unchanged:
|
scalar string, number, boolean, or explicit null. Field names form a set, so
|
||||||
a present higher-priority sequence replaces the lower sequence as one value,
|
order and repetition do not affect compound identity. The type-tagged tuple
|
||||||
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
|
preserves category distinctions: strings compare exactly, including empty
|
||||||
and application resolution do not run the registered identity check. The accepted
|
strings; numbers, booleans, and null remain distinct categories. Finite numbers
|
||||||
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
|
compare by exact mathematical value, so `1`, `1.0`, and `1e0` are equal, as are
|
||||||
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
`0` and `-0`, without floating-point rounding. Distinct tuples append in source
|
||||||
shows item path and glob imports, helper-supplied identity, and main-tier
|
and list order; duplicate tuples conflict even when records are otherwise equal
|
||||||
replacement of a lower-priority sequence.
|
or disjoint. Malformed records produce `compose.invalid`; duplicate identities
|
||||||
|
produce `compose.conflict`, with paths and source locations identifying a
|
||||||
|
duplicate without exposing identity values. Missing paths and non-sequence values
|
||||||
|
do not fail by themselves. Equivalent overlapping registrations coalesce.
|
||||||
|
Incompatible overlaps and invalid registrations produce `options.invalid` before
|
||||||
|
source acquisition. Unused and root registrations are allowed and have no effect.
|
||||||
|
|
||||||
|
Registrations provide identity for strict composition, sequence operations,
|
||||||
|
inheritance lookup, and final validation. Dynamic selectors apply at concrete
|
||||||
|
strict-composition, inheritance, and final-result destinations without requiring
|
||||||
|
the application to enumerate mapping keys. Each strict tier has independent
|
||||||
|
duplicate-identity state among its peers; layered precedence and inheritance
|
||||||
|
then apply the collection operation described below.
|
||||||
|
|
||||||
|
Final registration checks run in both ordinary and layered loads after effective
|
||||||
|
secret lookup and before application semantic validation. They examine actual
|
||||||
|
final typed sequences, including values introduced by defaults, CLI, or recorded
|
||||||
|
resolution; they do not require path existence or sequence shape. Diagnostics
|
||||||
|
use concrete collection and item paths and available source locations, without
|
||||||
|
exposing identity or credential values. The guarantee ends when loading publishes
|
||||||
|
successfully and does not cover later caller mutations.
|
||||||
|
YAML-only identity is enforced during structural composition, precedence, and
|
||||||
|
identified inheritance; it carries no final-result guarantee after typed
|
||||||
|
defaults, CLI, or application resolution. Applications that need that guarantee
|
||||||
|
register the identity constraint.
|
||||||
|
The [maintained catalog examples](consumers/README.md#identified-prompt-sequences)
|
||||||
|
show strict peers, precedence operations, inheritance, dynamic destinations, and
|
||||||
|
the distinction between YAML-only declarations and application registrations.
|
||||||
When an enabled source-version mode is selected, declarations are checked in each
|
When an enabled source-version mode is selected, declarations are checked in each
|
||||||
source's own coordinates before placement and composition. Accepted non-main
|
source's own coordinates before placement and composition. Accepted non-main
|
||||||
headers are consumed before strict composition and cannot supply a registered
|
headers are consumed before strict composition and cannot supply a registered
|
||||||
record identity. Final typed decoding and application validation still run on the
|
record identity. Final typed decoding and application validation still run on the
|
||||||
completed configuration.
|
completed configuration.
|
||||||
|
|
||||||
|
Ordinary sources, layered inputs, and selected overlays may declare collection
|
||||||
|
behavior under `configkit.sequences` at the document root. Each entry has a
|
||||||
|
required literal `destination` and may specify `key` as one string or a nonempty
|
||||||
|
list of strings, plus a `mode` of `replace`, `append`, or `upsert`. These
|
||||||
|
controls are removed before application decoding. A declaration destination is
|
||||||
|
relative to that source's placement, so a mounted fragment's declaration is
|
||||||
|
mounted with its values. Declarations from strict peers must agree on identity
|
||||||
|
fields and on any explicitly supplied mode; omission expresses no mode
|
||||||
|
preference. They apply even when the declaring source has no value at that
|
||||||
|
destination: a key validates an existing retained sequence or remains pending
|
||||||
|
until records arrive, while a mode-only declaration affects later combinations
|
||||||
|
without recombining earlier tiers. A key supplies the identity used by `append`
|
||||||
|
and `upsert` and is checked against any application registration.
|
||||||
|
|
||||||
|
Strict peers at an identified destination first combine distinct records in
|
||||||
|
source and list order. Duplicate identities among peers or within one
|
||||||
|
contribution fail under every mode. After strict composition, precedence and
|
||||||
|
inheritance use one operation for the assembled incoming collection:
|
||||||
|
|
||||||
|
| Mode | Behavior at source-precedence and inheritance boundaries |
|
||||||
|
| --- | --- |
|
||||||
|
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
|
||||||
|
| `append` | Retain lower records and append higher records; duplicate identities fail. |
|
||||||
|
| `upsert` | Replace matching records in full at their lower positions, retain unmatched lower records, and append new identities. |
|
||||||
|
|
||||||
|
Upsert replaces the complete matched record; it does not recursively merge
|
||||||
|
record fields. Append and upsert require identity. With no explicit mode in the
|
||||||
|
current tier, an explicit lower mode persists; if none exists, effective identity
|
||||||
|
selects upsert and its absence selects replace. Inferred modes do not persist.
|
||||||
|
Explicit modes persist at their destination until changed by a higher explicit
|
||||||
|
mode, independently of YAML identity lifetime. A declaration alone does not
|
||||||
|
clear or recombine a collection.
|
||||||
|
|
||||||
|
The presence and value kind at a precedence or inheritance boundary determine
|
||||||
|
whether a collection changes:
|
||||||
|
|
||||||
|
| Higher contribution | `replace` | `append` / `upsert` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
|
||||||
|
| Explicit `[]` | Clear the sequence. | Contribute no records. |
|
||||||
|
| Explicit null or another non-sequence value | Replace the lower value, subject to application schema. | Error; the operation requires a sequence. |
|
||||||
|
|
||||||
|
An absent lower value can be initialized by an incoming sequence under any mode.
|
||||||
|
A present lower null or other non-sequence value is not absence: append and
|
||||||
|
upsert cannot combine with it. An empty sequence still requires a valid mode and
|
||||||
|
identity where applicable. Null used as an identity component inside a record is
|
||||||
|
distinct from null used as the collection value.
|
||||||
|
|
||||||
|
Declarations govern ordinary strict source composition, including loads with
|
||||||
|
imports disabled, and each strict scope in layered loading. Before composing a
|
||||||
|
higher tier's peers, ConfigKit makes applicable lower identity and explicit mode
|
||||||
|
available; after peer composition, the tier applies its resolved operation
|
||||||
|
against the lower collection. Selected overlays use the same collection policy
|
||||||
|
after the base source graph is composed. During mapping-sibling inheritance, a
|
||||||
|
parent's YAML identity can follow its collection to the child destination, while
|
||||||
|
the parent's explicit operation stays at the parent destination. The child
|
||||||
|
destination selects its operation; absent one, effective identity implies
|
||||||
|
upsert and its absence implies replace. Whole-sequence replacement discards
|
||||||
|
lower YAML identity and uses the replacing scope's key declaration, if any;
|
||||||
|
without one, the collection is unkeyed unless an application registration still
|
||||||
|
supplies identity. Replacement discards policies owned by removed records, while
|
||||||
|
an independently declared explicit mode and application registration remain
|
||||||
|
applicable. Item-local
|
||||||
|
policies follow their owning records: retaining a record retains its rules, while
|
||||||
|
whole-record upsert replacement discards the displaced record's rules. Matching
|
||||||
|
identity or reusing an index does not transfer them. These policies do not change
|
||||||
|
CLI behavior: CLI sequence assignments replace whole sequences after decoding.
|
||||||
|
A root `configkit.imports` declaration still requires `Options.Imports`;
|
||||||
|
`NoSource` has no declaration scope.
|
||||||
|
|
||||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||||
Shared mapping containers retain all contributing value locations as their composite
|
Shared mapping containers retain all contributing value locations as their composite
|
||||||
base origin. A conflict reports the first claim as its primary location and every
|
base origin. A conflict reports the first claim in
|
||||||
remaining claim as related locations, in deterministic source and declaration
|
[scoped assembly order](#explicit-imports) as its primary location and every
|
||||||
traversal order. Traversal order never authorizes replacement. Composition clones
|
remaining claim as related locations in the same order. Assembly order never
|
||||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
authorizes replacement. Composition clones the resulting tree and its ordered
|
||||||
input document.
|
claim records rather than mutating or aliasing an input document.
|
||||||
|
|
||||||
## Selected overlays
|
## Selected overlays
|
||||||
|
|
||||||
@@ -827,13 +1049,21 @@ Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, wi
|
|||||||
no extension fallback, case normalization, inheritance between overlay files,
|
no extension fallback, case normalization, inheritance between overlay files,
|
||||||
or overlay stack. A selected missing overlay is an error.
|
or overlay stack. A selected missing overlay is an error.
|
||||||
|
|
||||||
|
With `NoSource`, the main input stays absent and the explicit filesystem context
|
||||||
|
supplies the overlay root. The overlay is recorded as the first acquired source
|
||||||
|
when no other source exists. Host contexts may use the root-only form described
|
||||||
|
under [single-source loading](#single-source-loading); native contexts retain
|
||||||
|
their complete namespace, base, and root requirements.
|
||||||
|
|
||||||
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
||||||
base import graph is composed and its schema version is checked, but before defaults
|
base import graph is composed and its schema version is checked, but before defaults
|
||||||
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||||
root and must not duplicate a file already present in the base graph.
|
root and must not duplicate a file already present in the base graph.
|
||||||
|
|
||||||
Overlay is intentionally distinct from strict composition:
|
Collection destinations follow the
|
||||||
|
[collection operation and presence rules](#strict-document-composition) above.
|
||||||
|
The following table describes ordinary overlay behavior elsewhere:
|
||||||
|
|
||||||
| Existing lower node | Incoming overlay node | Result |
|
| Existing lower node | Incoming overlay node | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -842,18 +1072,24 @@ Overlay is intentionally distinct from strict composition:
|
|||||||
| Mapping | Mapping | Recurse by mapping key |
|
| Mapping | Mapping | Recurse by mapping key |
|
||||||
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||||
|
|
||||||
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
|
Under these ordinary rules, scalars, sequences, and null are atomic. An overlay
|
||||||
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
sequence replaces rather than extends a base sequence, `[]` clears it, null is an
|
||||||
cross-kind replacement is allowed when the final application schema accepts it.
|
explicit replacement, and cross-kind replacement is allowed when the final
|
||||||
|
application schema accepts it.
|
||||||
Replacing a subtree removes all displaced descendants from effective presence and
|
Replacing a subtree removes all displaced descendants from effective presence and
|
||||||
origin data; history retains the displaced definitions and identifies the parent
|
origin data; history retains the displaced definitions and identifies the parent
|
||||||
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
||||||
mapping containers retain their contributing locations.
|
mapping containers retain their contributing locations.
|
||||||
|
|
||||||
An overlay may not contain `configkit` anywhere, contribute the configured schema
|
An overlay may contain only root `configkit.sequences` declarations; overlay
|
||||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
imports and nested directives are rejected. Sequence declarations are removed
|
||||||
apply even if the supplied version text would be unchanged. Overlays cannot mask a
|
before application decoding. At declared or registered sequence destinations,
|
||||||
base parse, composition, or version failure because those checks run first.
|
`replace`, `append`, and `upsert` use the same identity and policy rules as source
|
||||||
|
composition. Other overlay sequences remain atomic replacements. An overlay may
|
||||||
|
not contribute the configured schema version or replace an ancestor of that
|
||||||
|
version with a scalar or null. These rules apply even if the supplied version
|
||||||
|
text would be unchanged. Overlays cannot mask a base parse, composition, or
|
||||||
|
version failure because those checks run first.
|
||||||
|
|
||||||
## Explicit CLI overrides
|
## Explicit CLI overrides
|
||||||
|
|
||||||
@@ -865,10 +1101,16 @@ must never contain the raw argument or configured value. General environment
|
|||||||
variable overlays are an explicit non-goal: environment access is reserved for
|
variable overlays are an explicit non-goal: environment access is reserved for
|
||||||
typed secret references.
|
typed secret references.
|
||||||
|
|
||||||
Overrides apply after the selected overlay and before typed defaults/decoding, so
|
Overrides are prepared after source assembly and inheritance, then assigned after
|
||||||
the input precedence is application defaults < composed base < selected overlay <
|
typed defaults and decoding and before application resolution. The order is:
|
||||||
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
defaults and decoded sources < CLI < application resolution. A CLI mapping
|
||||||
effective override tree. Overrides also work with `NoSource`.
|
recursively overlays the decoded mapping; a CLI sequence replaces the complete
|
||||||
|
decoded sequence regardless of source collection operations. Inheritance and
|
||||||
|
typed defaulting do not run again after CLI assignment. Earlier parsing,
|
||||||
|
composition, inheritance, and decoding failures remain errors even if an override
|
||||||
|
would replace the failing value. Recorded resolution, typed secret lookup, and
|
||||||
|
semantic validation use the effective post-override values. Overrides also work
|
||||||
|
with `NoSource`.
|
||||||
|
|
||||||
Values are converted directly into ConfigKit's owned tree without YAML parsing or
|
Values are converted directly into ConfigKit's owned tree without YAML parsing or
|
||||||
string coercion. Supported values are `nil`, built-in strings and booleans, all
|
string coercion. Supported values are `nil`, built-in strings and booleans, all
|
||||||
@@ -879,7 +1121,8 @@ false, and zero remain intentional; empty sequences replace whole lists; and map
|
|||||||
values recursively overlay existing mappings. Map keys are sorted canonically and
|
values recursively overlay existing mappings. Map keys are sorted canonically and
|
||||||
sequence order is preserved.
|
sequence order is preserved.
|
||||||
|
|
||||||
All records are detached and validated before any is applied. Root and explicit
|
All records are detached and validated before any is applied. They cannot create
|
||||||
|
`configkit` or `extends` controls. Root and explicit
|
||||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
||||||
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||||
@@ -896,8 +1139,12 @@ unsupported types, nonfinite floats, and limit excesses fail with safe `override
|
|||||||
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
||||||
arguments or values.
|
arguments or values.
|
||||||
|
|
||||||
Override origins are explicit CLI origins. History retains replaced base/overlay
|
Override origins are explicit CLI origins. Resolution receives CLI values with
|
||||||
definitions and records equal-value replacements as well as structural changes.
|
their explicit-presence and source information. Absence-based defaults preserve
|
||||||
|
those values, while recorded normalization and derivation may change them. History
|
||||||
|
retains replaced source definitions and records equal-value replacements as well
|
||||||
|
as structural changes. Secret lookup and validation use post-resolution values
|
||||||
|
with their effective source locations.
|
||||||
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
||||||
concurrently with the call. The executable `ExampleOverride` shows translating a
|
concurrently with the call. The executable `ExampleOverride` shows translating a
|
||||||
parser's explicit-set state into records while preserving an explicitly supplied
|
parser's explicit-set state into records while preserving an explicitly supplied
|
||||||
@@ -905,16 +1152,22 @@ zero.
|
|||||||
|
|
||||||
## Explicit imports
|
## Explicit imports
|
||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may use its root
|
||||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
`configkit` mapping for `imports` and `sequences` controls. Ordinary `Load` may use
|
||||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
`sequences` without enabling imports, but an `imports` field still requires
|
||||||
string `path` or `glob`, an optional boolean `optional`, and an optional
|
`Options.Imports`. ConfigKit removes recognized root controls before application
|
||||||
`destination` sequence of strings. Every destination string is one literal
|
decoding. Each imports sequence item is a mapping with exactly one `path`,
|
||||||
mapping key and follows the input-destination validation rules above; quote
|
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
|
||||||
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
|
`glob` accepts one path pattern string. The optional `required` boolean controls
|
||||||
and non-string members are `import.invalid`, as are all other unsupported shapes
|
whether an empty selection or a missing named path is an error. Its default is
|
||||||
and fields. A `configkit` key below the root is invalid. With imports disabled,
|
false, so missing imports are absent unless `required: true` is specified. The
|
||||||
any occurrence remains `import.disabled`.
|
removed `optional` field is invalid. `destination` is an optional sequence of
|
||||||
|
strings. Every destination string is one literal mapping key and follows the
|
||||||
|
input-destination validation rules above; quote numeric-looking keys so YAML
|
||||||
|
reads them as strings. Null, scalar destinations, and non-string members are
|
||||||
|
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
|
||||||
|
key below the root is invalid. With imports disabled, root imports and nested
|
||||||
|
`configkit` keys remain `import.disabled`.
|
||||||
|
|
||||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||||
@@ -923,38 +1176,107 @@ directive keys in root files and imported fragments, and rename any application
|
|||||||
fields previously named `configkit` to avoid the newly reserved key.
|
fields previously named `configkit` to avoid the newly reserved key.
|
||||||
|
|
||||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
URLs are invalid. Each path resolves from its declaring file's canonical directory.
|
||||||
and files are traversed depth-first in declaration order: a file contributes its own
|
An empty `path: []` selects nothing; it succeeds even when `required: true` because
|
||||||
body before its imports. Order stabilizes source records and diagnostics but never
|
there are no named targets to be missing. A nonempty required path list verifies
|
||||||
permits replacement. Required missing paths fail; `optional: true` suppresses only
|
every named target, including targets later excluded from selection.
|
||||||
target nonexistence, not invalid paths, containment failures, directories,
|
|
||||||
permissions, parse errors, or composition errors.
|
Directory declarations search the listed literal directory roots. Missing roots
|
||||||
|
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
|
||||||
|
accepts one basename pattern or a list, matching filenames only. Omission selects
|
||||||
|
all filenames; an explicit empty list selects none. `exclude` accepts one basename
|
||||||
|
pattern or a list and removes matching final candidates. Patterns use Go's
|
||||||
|
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
|
||||||
|
`depth` is valid only when recursion is effective, with direct files at depth 1 and
|
||||||
|
depth 0 or omission meaning unlimited traversal subject to library budgets.
|
||||||
|
`follow` controls discovered symlink entries and `hidden` controls discovered
|
||||||
|
names beginning with a dot. Both default to true. Explicit roots retain their
|
||||||
|
normal access behavior.
|
||||||
|
|
||||||
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
||||||
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
|
classes, and backslash escapes are supported, while recursive `**` syntax is
|
||||||
Backslashes escape pattern characters rather than acting as directory separators.
|
invalid. Backslashes escape pattern characters rather than acting as directory
|
||||||
ConfigKit validates the complete pattern before filesystem traversal, walks only
|
separators. ConfigKit validates the complete pattern before filesystem traversal,
|
||||||
directories selected by preceding segments, and reads wildcard directories in
|
walks only directories selected by preceding segments, and reads wildcard
|
||||||
bounded batches. Every examined entry consumes the separate visit budget, including
|
directories in bounded batches. Every examined entry consumes the separate visit
|
||||||
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
|
budget, including nonmatches. Literal components are explicit path components;
|
||||||
each declaration before depth-first source traversal. A required zero-match glob is
|
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
|
||||||
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
|
final candidate basenames.
|
||||||
walk failures, matched directories, invalid documents, escapes, and repeated targets
|
|
||||||
remain errors. Final candidates are bounded by the remaining source limit before
|
|
||||||
their contents are accumulated.
|
|
||||||
|
|
||||||
Each path or glob declaration may include `placement: mapping` or
|
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` provide one
|
||||||
|
application-owned load-wide policy for enabled imports in the main source,
|
||||||
|
underlays, and transitive imports; they do not apply to selected overlays or
|
||||||
|
discover sources by themselves. Each applicable field resolves independently:
|
||||||
|
an explicit declaration value wins, then the application default, then the
|
||||||
|
library default (`required: false`, `follow: true`, `hidden: true`,
|
||||||
|
`recursive: false`, and unrestricted filenames). A declaration's `pattern`
|
||||||
|
replaces rather than extends the default pattern. Nil `Pattern` leaves directory
|
||||||
|
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
|
||||||
|
a field applicable to selectors that do not support it, and load calls snapshot
|
||||||
|
the supplied pointers and slice before source acquisition.
|
||||||
|
|
||||||
|
A source's `configkit.sequences` declarations apply independently of import
|
||||||
|
enablement; a `configkit.imports` declaration still requires `Options.Imports`.
|
||||||
|
Sequence identities can be supplied either by these YAML declarations or by
|
||||||
|
application registrations. The [strict composition contract](#strict-document-composition)
|
||||||
|
defines their agreement, operation, and lifetime rules.
|
||||||
|
|
||||||
|
Acquisition visits declarations in authored order and each declaration's selected
|
||||||
|
paths in namespace-path lexical order, preserving source-record and version-callback
|
||||||
|
order. Ordinary mapping contributions in a main tier are assembled dependency-first:
|
||||||
|
ConfigKit selects the lexically smallest stable source key among the contributions
|
||||||
|
whose imports are complete. Independent branches may interleave, and the main body
|
||||||
|
may be assembled after its imports. Sequence-item contributions in that tier are
|
||||||
|
nodes in the same schedule; each selected item first assembles its own strict
|
||||||
|
mapping scope, with helpers before the bodies that import them. Those local scopes
|
||||||
|
do not use application collection policies and are not flattened into the outer
|
||||||
|
schedule. Item positions and diagnostic paths follow the resulting assembly order.
|
||||||
|
Each underlay group likewise acquires its inputs before dependency-ready assembly
|
||||||
|
in one strict scope. Independent groups remain separate precedence tiers; at a
|
||||||
|
declared or registered destination, the assembled higher sequence uses the
|
||||||
|
resolved collection operation against the lower sequence. Other sequences retain
|
||||||
|
whole-value replacement between tiers. Assembly order never permits replacement.
|
||||||
|
|
||||||
|
Acquisition order and resolution order serve different purposes: acquisition
|
||||||
|
assigns source records and callback order, while the shared scheduler determines
|
||||||
|
composition order among currently dependency-ready contributions. Inheritance
|
||||||
|
uses the same eligibility rule within each destination-local sibling scope; it
|
||||||
|
does not reorder the authored output mapping. The private namespace and stable-key
|
||||||
|
construction is described in the [import implementation overview](internal/imports.md).
|
||||||
|
|
||||||
|
An empty selection is allowed by default. `required: true` makes an empty directory
|
||||||
|
or glob selection fail with `import.missing`; it also makes missing named paths
|
||||||
|
fail. Requiredness does not suppress invalid paths, containment failures,
|
||||||
|
directories selected as files, permissions, parse errors, or composition errors.
|
||||||
|
|
||||||
|
For inspection, `SourceInfo.ImportSelections` records one outcome for each
|
||||||
|
completed optional declaration whose entire selected set is empty. The record is
|
||||||
|
attached to its declaring source, keeps the declaration path local to that source,
|
||||||
|
identifies the declaration mapping (including its `SourceID`) and selector, and
|
||||||
|
uses outcome `none`. Records remain in declaration order and are available
|
||||||
|
through the existing result, load
|
||||||
|
error, and callback source snapshots. They do not enumerate candidates, explain
|
||||||
|
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
|
||||||
|
regardless of requiredness: it produces a `none` record when effectively optional,
|
||||||
|
and no record when effectively required. Snapshots detach the record paths and
|
||||||
|
slices.
|
||||||
|
|
||||||
|
Each declaration may include `placement: mapping` or
|
||||||
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
||||||
including every match of a glob; placement is not inherited. Sequence-item
|
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 combine only when the destination
|
||||||
registered through `Definition.SequenceCompositions`; registration does not
|
has an applicable YAML identity declaration or application registration; source
|
||||||
change placement or precedence behavior. Sequence records retain source
|
placement remains independent of collection identity and operation. Sequence
|
||||||
locations through placement and history; indexes describe their assembled
|
records retain source locations through placement and history; indexes describe
|
||||||
positions while source coordinates continue to identify the declaring files.
|
their assembled positions while source coordinates continue to identify the
|
||||||
Header/version policy and final application validation retain their existing
|
declaring files.
|
||||||
owners and timing; sequence registration is a strict-composition rule only.
|
Header/version policy retains its existing owner and timing. Application
|
||||||
|
registrations also enforce identity on the final effective typed configuration;
|
||||||
|
YAML-only declarations are enforced at structural combination and inheritance
|
||||||
|
boundaries. The [configuration contract](#strict-document-composition) defines
|
||||||
|
these validation boundaries.
|
||||||
|
|
||||||
An item import first opens a local mapping scope. Its body and ordinary helper
|
An item import first opens a local mapping scope. Its body and ordinary helper
|
||||||
imports compose within that record. An omitted helper destination or
|
imports compose within that record. An omitted helper destination or
|
||||||
@@ -964,8 +1286,8 @@ the record's `id`. An explicit nested `sequence-item` import creates a sequence
|
|||||||
inside the record, and its placement is independent of the enclosing item.
|
inside the record, and its placement is independent of the enclosing item.
|
||||||
At the record root, an item import must name a nonempty nested destination.
|
At the record root, an item import must name a nonempty nested destination.
|
||||||
Physical paths continue to resolve from the file that declares each import.
|
Physical paths continue to resolve from the file that declares each import.
|
||||||
An actual empty mapping contributes an empty item, while an optional unmatched
|
An actual empty mapping contributes an empty item, while an unmatched declaration
|
||||||
path or glob contributes nothing.
|
with default or explicit `required: false` contributes nothing.
|
||||||
|
|
||||||
For host files, the configuration root defaults to the selected file's canonical
|
For host files, the configuration root defaults to the selected file's canonical
|
||||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||||
@@ -984,8 +1306,13 @@ ConfigKit canonicalizes symlinks,
|
|||||||
checks containment with relative paths, and opens the canonical root-relative target
|
checks containment with relative paths, and opens the canonical root-relative target
|
||||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||||
distinct paths remain distinct sources. Native filesystems use slash-path operations
|
distinct paths remain distinct sources. Native filesystems use slash-path
|
||||||
and their own symlink behavior; imports stay in their selected namespace and root.
|
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
|
||||||
|
that capability to inspect and resolve discovered links; otherwise it follows
|
||||||
|
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
|
||||||
|
native filesystem to expose link inspection, and native filesystem behavior is
|
||||||
|
not a general sandbox guarantee. Imports stay in their selected namespace and
|
||||||
|
root.
|
||||||
|
|
||||||
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
||||||
sites. A previously visited canonical path is `import.repeated` and reports both
|
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ applications. Consumers still own their configuration structs, defaults, semanti
|
|||||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||||
resource checks, remote acquisition, and service initialization.
|
resource checks, remote acquisition, and service initialization.
|
||||||
|
|
||||||
|
## Choosing a loading entry point
|
||||||
|
|
||||||
|
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
|
||||||
|
[`SearchConfig`](../../discovery.go) is also a source, so its explicit
|
||||||
|
discovery result can be passed to `Load` or used as the main input to
|
||||||
|
[`LoadLayered`](../../layered.go).
|
||||||
|
- Use `LoadLayered` when inputs must be composed in underlay groups before the
|
||||||
|
main source. Each [`Input`](../../layered.go) owns its source, filesystem,
|
||||||
|
destination, and import choice. The overlay filesystem context is selected
|
||||||
|
independently in [`LayeredOptions`](../../layered.go).
|
||||||
|
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||||
|
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||||
|
version policy), import defaults, and explicit overrides. These do not turn
|
||||||
|
on imports or discovery by themselves. See the
|
||||||
|
[configuration contract](../config.md#layered-loading) and
|
||||||
|
[discovery contract](../config.md#configuration-discovery) for exact rules.
|
||||||
|
|
||||||
|
The root [README example](../../README.md) shows the small `Load` path.
|
||||||
|
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
|
||||||
|
file and layered cases. For import-focused adoption, see the
|
||||||
|
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||||
|
discovery composed with sequences, see the
|
||||||
|
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||||
|
|
||||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
||||||
sanitized shapes using only ConfigKit and the Go standard library:
|
sanitized shapes using only ConfigKit and the Go standard library:
|
||||||
|
|
||||||
@@ -21,7 +45,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 |
|
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog 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,
|
||||||
@@ -55,6 +79,39 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
|||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
execution profile concepts.
|
||||||
|
|
||||||
|
## Explicit main-file discovery
|
||||||
|
|
||||||
|
Applications choose the search conventions and their precedence, then pass the
|
||||||
|
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
|
||||||
|
For example, the maintained sequence catalog puts a caller-selected file before
|
||||||
|
a custom directory fallback while keeping its underlay and overlay choices
|
||||||
|
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
|
||||||
|
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
|
||||||
|
the [working-directory executable example](../../examples/consumers/discovery_test.go).
|
||||||
|
Together, the root-owned [discovery API](../../discovery.go) and [configuration
|
||||||
|
discovery contract](../config.md#configuration-discovery) define ordered
|
||||||
|
concatenation, directory-first Cartesian expansion, optional exhaustion, and
|
||||||
|
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
|
records why discovery is explicitly selected.
|
||||||
|
|
||||||
|
## Import defaults and selection inspection
|
||||||
|
|
||||||
|
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||||
|
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||||
|
is resolved independently: a declaration's explicit value wins, then the
|
||||||
|
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
||||||
|
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
||||||
|
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||||
|
slice selects no files. A declaration's own `pattern` replaces the application
|
||||||
|
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||||
|
|
||||||
|
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||||
|
declaring source's `ImportSelections`. These records identify the source-local
|
||||||
|
declaration path, its authored location and selector, and the `none` outcome;
|
||||||
|
they do not include candidate names or a guessed reason for absence. Returned
|
||||||
|
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||||
|
owns selector applicability and exact absence semantics.
|
||||||
|
|
||||||
## Shared source-version checks
|
## Shared source-version checks
|
||||||
|
|
||||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||||
@@ -166,21 +223,35 @@ import, source-location, and ownership behavior.
|
|||||||
|
|
||||||
## Identified prompt sequences
|
## Identified prompt sequences
|
||||||
|
|
||||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) shows the
|
||||||
definition registration for the literal `prompts` path and `id` field. Its main
|
smaller import-focused adoption path: a registered `prompts.id` identity,
|
||||||
file imports one prompt by path and others by glob, placing each source as an
|
directory and glob selection, a helper import that supplies an item's ID, and
|
||||||
item. A nested ordinary helper import supplies an item's ID. The main tier
|
resource lookup through the effective source context. Its executable examples
|
||||||
strictly appends the distinct records in contribution order; its completed
|
and tests are in
|
||||||
sequence replaces the lower-priority default collection as one value.
|
[`prompt_catalog_test.go`](../../examples/consumers/prompt_catalog_test.go), with
|
||||||
|
complete [catalog](../../examples/consumers/testdata/prompt-catalog/config.yml)
|
||||||
|
and [required-file](../../examples/consumers/testdata/prompt-catalog/simple.yml)
|
||||||
|
fixtures.
|
||||||
|
|
||||||
The example's `validatePromptCatalog` remains application-owned. It checks that
|
[`LoadSequenceCatalog`](../../examples/consumers/sequence_catalog.go) integrates
|
||||||
prompt names and message lists satisfy this consumer's requirements after
|
an underlay declaration, imported main fragments, identified-record inheritance,
|
||||||
ConfigKit has composed, projected, and decoded the configuration. It does not
|
selected overlay `append`, `upsert`, and `replace`, a typed CLI override, and
|
||||||
add recursive record merging, identity-based overlay, or final uniqueness checks
|
recorded application resolution. Its tests and runnable example demonstrate
|
||||||
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
|
stable output order, whole-record upsert, provenance/history inspection,
|
||||||
[import syntax](../config.md#explicit-imports), and
|
resource lookup for inherited records, and secret-safe explanation in
|
||||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
|
[`sequence_catalog_test.go`](../../examples/consumers/sequence_catalog_test.go).
|
||||||
boundaries.
|
[`LoadNamespacedMapCatalog`](../../examples/consumers/sequence_catalog.go) adds
|
||||||
|
an exact root registration and a wildcard namespace registration, showing that
|
||||||
|
each concrete sequence has an independent identity scope. The same tests show
|
||||||
|
that a YAML identity declaration without an application registration does not
|
||||||
|
create final-result identity enforcement.
|
||||||
|
|
||||||
|
The examples keep prompt meaning and domain checks in the consuming application.
|
||||||
|
The [configuration contract](../config.md#strict-document-composition) owns
|
||||||
|
declaration, identity, operation, ownership, and final-validation semantics;
|
||||||
|
[sibling inheritance](../config.md#sibling-inheritance) defines record
|
||||||
|
relationships, and the [CLI contract](../config.md#explicit-cli-overrides) owns
|
||||||
|
override ordering.
|
||||||
|
|
||||||
Known module catalogs should use canonical object entries with a module identifier
|
Known module catalogs should use canonical object entries with a module identifier
|
||||||
and typed options. A small union struct is sufficient when the available option
|
and typed options. A small union struct is sufficient when the available option
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public behavior.
|
|||||||
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
||||||
| Documentation changes | The owning document |
|
| Documentation changes | The owning document |
|
||||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||||
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
|
| Architecture decisions or future work | [Future features](roadmap/future.md), [consolidated audit review](roadmap/audit.md), [remediation roadmap](roadmap/roadmap.md), and relevant ADRs listed below |
|
||||||
| Code and test changes | The owning contract and tests from the map below |
|
| Code and test changes | The owning contract and tests from the map below |
|
||||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||||
| Release preparation | [Release procedure](release.md) |
|
| Release preparation | [Release procedure](release.md) |
|
||||||
@@ -59,6 +59,11 @@ Accepted decisions:
|
|||||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||||
|
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
|
||||||
|
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||||
|
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||||
|
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||||
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -108,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Remaining optimization candidates belong to
|
Remaining optimization candidates belong to
|
||||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
[future performance experiments](roadmap/future.md#5-performance-experiments).
|
||||||
|
|
||||||
The completed effective-configuration diff roadmap, implementation plan, and
|
The completed effective-configuration diff roadmap, implementation plan, and
|
||||||
resolved review findings are preserved in Git history at `1c2610c` under
|
resolved review findings are preserved in Git history at `1c2610c` under
|
||||||
@@ -131,12 +136,13 @@ Current behavior belongs to the configuration contract's
|
|||||||
[input destinations](config.md#input-destinations),
|
[input destinations](config.md#input-destinations),
|
||||||
[imports](config.md#explicit-imports), and
|
[imports](config.md#explicit-imports), and
|
||||||
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||||
Explicit sequence-item placement and registered strict composition are owned by
|
Explicit sequence-item placement, collection operations, identity registration,
|
||||||
the [strict composition](config.md#strict-document-composition) and
|
and final registered-identity validation are owned by the
|
||||||
[imports](config.md#explicit-imports) sections. The
|
[strict composition](config.md#strict-document-composition) section;
|
||||||
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
[imports](config.md#explicit-imports) owns acquisition and scheduling. The
|
||||||
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
|
[maintained sequence catalog](consumers/README.md#identified-prompt-sequences)
|
||||||
records the decision.
|
shows the consumer boundary, and [ADR 0017](adr/0017-collection-sequence-policy.md)
|
||||||
|
records the collection decision.
|
||||||
|
|
||||||
The completed sequence-composition roadmap, implementation plan, and resolved
|
The completed sequence-composition roadmap, implementation plan, and resolved
|
||||||
remediation record are preserved in Git history at `008d2ba` under
|
remediation record are preserved in Git history at `008d2ba` under
|
||||||
@@ -148,7 +154,56 @@ git show 008d2ba:docs/roadmap/implementation.md
|
|||||||
git show 008d2ba:docs/roadmap/remediation.md
|
git show 008d2ba:docs/roadmap/remediation.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The completed import-discovery roadmap, implementation plan, and resolved
|
||||||
|
remediation record are preserved in Git history at `5747ec4` under
|
||||||
|
`docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 5747ec4:docs/roadmap/imports.md
|
||||||
|
git show 5747ec4:docs/roadmap/implementation.md
|
||||||
|
git show 5747ec4:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The subsequent import-integration audit and completed remediation record are
|
||||||
|
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||||
|
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||||
|
|
||||||
|
The completed sequence-identity/collection-policy roadmap and implementation
|
||||||
|
plan are preserved in Git history at `d0a605a`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show d0a605a:docs/roadmap/sequences.md
|
||||||
|
git show d0a605a:docs/roadmap/implementation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Current behavior belongs to the configuration contract's
|
||||||
|
[strict composition](config.md#strict-document-composition),
|
||||||
|
[sibling inheritance](config.md#sibling-inheritance), and
|
||||||
|
[CLI overrides](config.md#explicit-cli-overrides) sections.
|
||||||
|
The completed remediation record, including the algorithm-audit findings, is
|
||||||
|
preserved at `ea46001:docs/roadmap/remediation.md`.
|
||||||
|
|
||||||
|
The completed configuration-discovery roadmap, implementation plan, and
|
||||||
|
remediation record are preserved in Git history at `0369111`, for example
|
||||||
|
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
|
||||||
|
[configuration discovery](config.md#configuration-discovery); the public
|
||||||
|
[discovery declarations](../discovery.go) own the Go API.
|
||||||
|
|
||||||
|
The completed consolidation proposal, implementation plan, and resolved audit
|
||||||
|
findings are preserved in Git history at `34cbc0d` under `docs/roadmap/`. For
|
||||||
|
example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 34cbc0d:docs/roadmap/implementation.md
|
||||||
|
git show 34cbc0d:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||||
|
The [consolidated audit review](roadmap/audit.md) owns the open findings from
|
||||||
|
the nine 2026-10-25 audits, their evidence, and settled scope decisions. The
|
||||||
|
[remediation roadmap](roadmap/roadmap.md) owns the combined target state and
|
||||||
|
high-level dependency order. The original reports are preserved at `ecd5d5d`
|
||||||
|
under `docs/roadmap/`.
|
||||||
|
|
||||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
Acquire declared dependencies explicitly before entering an offline
|
Acquire declared dependencies explicitly before entering an offline
|
||||||
@@ -196,14 +251,17 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Task | Behavioral test owners | Focused command |
|
| Task | Behavioral test owners | Focused command |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||||
|
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
|
||||||
|
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
|
||||||
|
| Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||||
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
||||||
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||||
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||||
@@ -211,6 +269,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||||
|
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
|
||||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
|
|||||||
125
docs/internal/imports.md
Normal file
125
docs/internal/imports.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Import Discovery and Scheduling Internals
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#explicit-imports) owns selector syntax,
|
||||||
|
defaults, requiredness, placement, inspection evidence, and observable ordering.
|
||||||
|
This note records implementation flow and private identities for maintainers.
|
||||||
|
|
||||||
|
## Discovery and acquisition
|
||||||
|
|
||||||
|
Each source's root directive is parsed into presence-aware declarations and
|
||||||
|
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
|
||||||
|
policy, load-wide accounting, and tier-scoped repetition/namespace state;
|
||||||
|
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
|
||||||
|
and import admission. Candidate
|
||||||
|
selection in `import_selection.go` preserves a normalized
|
||||||
|
namespace path separately from canonical access identity: overlapping roots
|
||||||
|
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||||
|
through distinct spellings retain separate inclusion routes for admission.
|
||||||
|
Directory searches walk each distinct normalized root once, apply all filename
|
||||||
|
patterns to discovered names, and form a set union of matching candidates across
|
||||||
|
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||||
|
candidate normalization. They do not suppress discovery or resource errors.
|
||||||
|
|
||||||
|
Directory traversal tracks canonical directories on the active traversal path.
|
||||||
|
It adds an identity when descending and removes it when the frame is popped, so
|
||||||
|
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||||
|
not mistaken for one. This is separate from import dependency-cycle detection.
|
||||||
|
For host sources, discovery uses rooted `Lstat` and canonical containment.
|
||||||
|
`filesystem_context.go` owns resolved access facts and live access setup; the
|
||||||
|
acquisition operation closes its live handles before composition. Native sources
|
||||||
|
use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
|
||||||
|
capabilities and behavior of the provided `fs.FS`, without claiming a general
|
||||||
|
sandbox guarantee. `source_context.go` derives passive resource contexts from
|
||||||
|
resolved sources for later caller-owned reads; those records do not retain live
|
||||||
|
acquisition handles.
|
||||||
|
|
||||||
|
Host and native link bindings share one iterative link-frame evaluator. It
|
||||||
|
tracks active links for cycle detection and reuses successfully completed link
|
||||||
|
targets while resolving the remaining path components; each binding retains its
|
||||||
|
own namespace and target-containment rules. Directory reads distinguish an
|
||||||
|
initial-open failure from failures after opening: only an initial missing
|
||||||
|
directory can be treated as optional, while enumeration and close failures
|
||||||
|
remain selection errors, including errors from the standard `fs.ReadDir`
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
Glob selection keeps traversal coordination in `selectGlobImportCandidates`:
|
||||||
|
it advances literal and patterned segments, reads directories, unions and sorts
|
||||||
|
candidates, validates final targets, then applies exclusions and requiredness.
|
||||||
|
For each patterned directory entry, `prepareGlobEntry` handles matching and
|
||||||
|
hidden filtering, containment, target and link inspection, intermediate-directory
|
||||||
|
eligibility, host/native access-path selection, and candidate identity and label.
|
||||||
|
It returns a candidate, an ordinary skip, or a diagnostic without mutating the
|
||||||
|
selector's candidate set or traversal state. Directory entry preparation remains
|
||||||
|
separate because its matching, absence, recursion, and traversal-state rules
|
||||||
|
differ.
|
||||||
|
|
||||||
|
Acquisition remains authored: declarations are visited in source order and each
|
||||||
|
selected candidate set in namespace-path lexical order. This preserves source
|
||||||
|
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||||
|
inclusion graph is retained separately from composition scopes so cycles that
|
||||||
|
cross item boundaries remain detectable. Source discovery does not itself choose
|
||||||
|
sequence assembly order.
|
||||||
|
|
||||||
|
## Stable keys and scheduling
|
||||||
|
|
||||||
|
The shared scheduler compares immutable tuples component by component using Go's
|
||||||
|
bytewise string order; if one tuple is a prefix of another, the shorter tuple
|
||||||
|
sorts first. The import key is
|
||||||
|
`(namespace-kind, namespace-anchor, resource-kind, resource-path)`:
|
||||||
|
|
||||||
|
- Namespace kind is `host` or `native`. Host resource paths are canonical
|
||||||
|
absolute paths rendered with slash separators; native paths retain their
|
||||||
|
namespace-relative identity.
|
||||||
|
- Anchors come from explicit input positions, not discovery order: main is
|
||||||
|
`(0,0)` and underlay/input positions are `(group+1,input+1)`, each encoded as
|
||||||
|
fixed-width decimal. Host accesses in one scheduled tier share its host anchor.
|
||||||
|
A native filesystem namespace uses its earliest explicit selecting position
|
||||||
|
in that tier. Filesystem tokens distinguish namespace equality, not sort order,
|
||||||
|
so separately constructed filesystems with the same label remain distinct.
|
||||||
|
- Resource kind is `file` or `synthetic`. A synthetic root uses its explicit
|
||||||
|
input anchor and an empty resource path rather than a display label or token
|
||||||
|
address.
|
||||||
|
|
||||||
|
Inheritance keys are
|
||||||
|
`("definition", scope-pointer, definition-pointer)`, where both pointers use
|
||||||
|
canonical escaped semantic path spelling. Source IDs are not part of the key.
|
||||||
|
|
||||||
|
Both integrations use the same Kahn scheduler: it deduplicates dependency edges,
|
||||||
|
keeps currently ready nodes in a min-heap, and selects the smallest ready key
|
||||||
|
after every completion. Its scheduling cost is `O(E + V log V)` time and
|
||||||
|
`O(V + E)` additional memory, excluding key construction, tuple copying, and
|
||||||
|
tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
|
||||||
|
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||||
|
reported as cycle members.
|
||||||
|
|
||||||
|
Import edge preparation builds an importer-to-target adjacency index once for
|
||||||
|
an acquisition's outer and item-local scheduling, and once for grouped
|
||||||
|
scheduling. Each scope gathers edges from its participating identities and
|
||||||
|
retains only edges with both endpoints in its local slots. The retained
|
||||||
|
inclusion graph still owns cycle diagnostics and source relationships.
|
||||||
|
|
||||||
|
Import composition schedules a main tier or one underlay group as a single
|
||||||
|
strict-scope graph. Applicable collection declarations are gathered for the
|
||||||
|
scope before peer composition, including identity inherited from lower tiers.
|
||||||
|
Identified peer sequences then combine distinct records in assembled source and
|
||||||
|
list order; after the strict scope completes, layered assembly applies the
|
||||||
|
resolved collection operation against the lower value, as defined by the
|
||||||
|
[configuration contract](../config.md#strict-document-composition). Item
|
||||||
|
contributions own nested local strict-mapping scopes; their helpers complete
|
||||||
|
before the item is placed in its outer scope. Inheritance uses the same
|
||||||
|
ready-node rule inside each sibling mapping scope, while preserving the authored
|
||||||
|
mapping order in its output.
|
||||||
|
These scopes do not flatten into one global graph, and independent precedence
|
||||||
|
tiers remain separate.
|
||||||
|
|
||||||
|
The owners are [`acquisition.go`](../../internal/engine/acquisition.go),
|
||||||
|
[`import_selection.go`](../../internal/engine/import_selection.go),
|
||||||
|
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||||
|
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||||
|
[`import_graph.go`](../../internal/engine/import_graph.go),
|
||||||
|
[`import_projection.go`](../../internal/engine/import_projection.go),
|
||||||
|
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
|
||||||
|
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
||||||
|
Their focused behavior is covered by import-selection, acquisition, scheduler,
|
||||||
|
and scheduling integration tests under `internal/engine`; see the
|
||||||
|
[development test map](../development.md#task-to-test-map).
|
||||||
@@ -7,28 +7,41 @@ private state; public signatures and fields do not expose engine types.
|
|||||||
|
|
||||||
Implemented component owners:
|
Implemented component owners:
|
||||||
|
|
||||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
- `internal/engine` owns host and native-filesystem source acquisition and
|
||||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
explicitly selected configuration discovery. A search resolves once during
|
||||||
and sequence-item placement, definition-registered distinct-ID sequence
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
composition within strict tiers, and immediate-parent-relative import
|
follows the existing composition, metadata, and resource-context lifecycle.
|
||||||
placement within that assembly. Higher-priority sequences replace lower
|
It also owns grouped strict composition, recursive precedence assembly,
|
||||||
sequences as whole values. The engine also owns destination-local sibling
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
inheritance before typed decoding, plus shared source-version checking with
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
sanitized declaration evidence.
|
composition, inheritance, and final validation. It applies the selected
|
||||||
|
collection operation at precedence boundaries and owns dependency-ready,
|
||||||
|
destination-local inheritance before typed decoding. CLI assignment follows
|
||||||
|
inheritance and decoding, before application resolution. The engine also owns
|
||||||
|
shared source-version checking with sanitized declaration evidence.
|
||||||
|
Explicit import discovery supports declared paths, directory searches, and
|
||||||
|
globs; root-owned defaults are converted to the engine's single defaults input
|
||||||
|
and snapshotted during load preflight before acquisition, while source-local
|
||||||
|
empty-selection evidence crosses the facade without changing acquisition
|
||||||
|
order. The root constructs placement directly on engine options and inputs
|
||||||
|
using explicit enum conversion; public types and fields remain root-owned.
|
||||||
|
Import and inheritance resolution share dependency-ready scheduling within
|
||||||
|
their composition scopes.
|
||||||
It retains each source's effective placement alongside physical source
|
It retains each source's effective placement alongside physical source
|
||||||
identity and owns schema
|
identity and owns schema
|
||||||
inspection, lifecycle coordination, callback auditing,
|
inspection, lifecycle coordination, callback auditing,
|
||||||
provenance/history, passive source-context retention, secret resolution and
|
provenance/history, passive source-context retention, secret resolution and
|
||||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
bootstrap, operation-local secret failure-location attribution, safe
|
||||||
|
inspection rendering, and effective-value comparison over
|
||||||
frozen inspection snapshots. It never imports the root package.
|
frozen inspection snapshots. It never imports the root package.
|
||||||
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
||||||
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
||||||
or records. Callback wrappers pass only live engine context during a callback;
|
or records. Callback wrappers pass only live engine context during a callback;
|
||||||
`Result.Config` is detached and its private inspection remains frozen
|
`Result.Config` is detached and its private inspection remains frozen
|
||||||
independently.
|
independently.
|
||||||
- Root conversion files explicitly cross the path, record, callback, secret, and
|
- Root conversion files explicitly cross the path, record, callback, secret,
|
||||||
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
search-description, and error boundaries. There are no public aliases,
|
||||||
public engine bridge methods.
|
anonymous engine embeddings, or public engine bridge methods.
|
||||||
- Root `Diff` and `Difference` provide the public comparison boundary and
|
- Root `Diff` and `Difference` provide the public comparison boundary and
|
||||||
convert detached engine records to root-owned paths and snapshots; consumer
|
convert detached engine records to root-owned paths and snapshots; consumer
|
||||||
examples own presentation of the safe changed regions.
|
examples own presentation of the safe changed regions.
|
||||||
@@ -48,13 +61,16 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
|||||||
internal types.
|
internal types.
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||||
semantics, including [input destinations](../config.md#input-destinations),
|
semantics, including
|
||||||
|
[configuration discovery](../config.md#configuration-discovery),
|
||||||
|
[input destinations](../config.md#input-destinations),
|
||||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||||
The [strict composition contract](../config.md#strict-document-composition)
|
The [strict composition contract](../config.md#strict-document-composition)
|
||||||
owns sequence placement, registration, replacement, and history behavior; the
|
owns sequence declarations, identity, operations, ownership, and final
|
||||||
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
|
validation; [CLI overrides](../config.md#explicit-cli-overrides) own override
|
||||||
maintained example.
|
ordering. The [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||||
|
links maintained workflow examples.
|
||||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||||
The [architecture policy](../policy/architecture.md) owns contributor
|
The [architecture policy](../policy/architecture.md) owns contributor
|
||||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||||
@@ -67,4 +83,78 @@ post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-desti
|
|||||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||||
records the source-version decision.
|
records the source-version decision.
|
||||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||||
sequence placement and composition.
|
sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||||
|
records the shared dependency-ready ordering decision; [ADR
|
||||||
|
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||||
|
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
||||||
|
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
|
records explicit main-file discovery. The [import internals](imports.md)
|
||||||
|
document candidate and namespace identities, discovery and scheduling, and the
|
||||||
|
bounded algorithms.
|
||||||
|
|
||||||
|
## Implementation flow
|
||||||
|
|
||||||
|
The root facade converts caller-owned values into engine-owned requests. Its
|
||||||
|
`ImportDefaults` conversion maps fields without taking ownership; engine load
|
||||||
|
preflight snapshots the defaults once before source acquisition or callbacks.
|
||||||
|
Ordinary and grouped loading then carry that read-only value through their
|
||||||
|
normalized `loadRequest` and acquisition policy, while declaration resolution
|
||||||
|
copies values needed by its independently owned effective policy. Placement is
|
||||||
|
constructed directly on the engine's options and input records after explicit
|
||||||
|
root-to-engine enum conversion. See
|
||||||
|
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
|
||||||
|
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
||||||
|
version, default-import, and override policy stays distinct from each input's
|
||||||
|
source, filesystem, destination, and import settings.
|
||||||
|
|
||||||
|
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
||||||
|
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
||||||
|
input is acquired under that policy; grouped requests retain acquired underlay
|
||||||
|
scopes until their tier is composed in
|
||||||
|
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
|
||||||
|
share one assembly handoff that applies collection declarations and projects
|
||||||
|
only that tier's source/version evidence; the caller keeps main-version
|
||||||
|
protection and its projection explicit. Live
|
||||||
|
filesystem access handles are confined to acquisition and closed there. Resolved
|
||||||
|
filesystem facts used to authorize access are separate from passively retained
|
||||||
|
resource contexts. Non-import validation retains those facts on `assembledInput`
|
||||||
|
for passive-context construction and ordinary-overlay handling: see
|
||||||
|
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
|
||||||
|
[`source_context.go`](../../internal/engine/source_context.go).
|
||||||
|
|
||||||
|
Import responsibilities are split across candidate selection and path
|
||||||
|
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||||
|
source reading and traversal admission in
|
||||||
|
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||||
|
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||||
|
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
|
||||||
|
and projection of acquired evidence into final source coordinates in
|
||||||
|
[`import_projection.go`](../../internal/engine/import_projection.go). The
|
||||||
|
[import internals](imports.md) page describes the boundaries and identities.
|
||||||
|
|
||||||
|
After acquisition handles are closed, grouped composition and inheritance run on
|
||||||
|
the retained ordered documents. `inheritance.go` coordinates traversal and
|
||||||
|
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
|
||||||
|
dependency-ready templates;
|
||||||
|
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
|
||||||
|
prepares child and parent inputs,
|
||||||
|
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
|
||||||
|
returns the combined value with its origin, policy, sequence, and diagnostic
|
||||||
|
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
|
||||||
|
accounts for and finalizes the result. Projection and schema work have separate
|
||||||
|
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
|
||||||
|
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
|
||||||
|
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
|
||||||
|
descriptor caching and type policy, and
|
||||||
|
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
|
||||||
|
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
||||||
|
those descriptors for typed decoding.
|
||||||
|
|
||||||
|
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
|
||||||
|
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
||||||
|
discovery, and destination tests; inheritance by the inheritance test files;
|
||||||
|
reflection behavior by `schema_test.go`, `projection_test.go`, and
|
||||||
|
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
||||||
|
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
|
||||||
|
copying, using the shared descriptors and per-operation traversal helpers.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-10-16
|
**Last Updated**: 2026-10-22
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
|
|
||||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
|||||||
Constraints must reflect deliberately selected requirements and decisions.
|
Constraints must reflect deliberately selected requirements and decisions.
|
||||||
Do not promote incidental implementation choices into architectural invariants.
|
Do not promote incidental implementation choices into architectural invariants.
|
||||||
|
|
||||||
|
The implemented collection-policy and CLI-ordering constraints below reflect
|
||||||
|
accepted [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The configuration
|
||||||
|
contract owns their current behavior; these architectural constraints describe
|
||||||
|
the system boundaries and guarantees that guide changes to that behavior.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||||
@@ -91,13 +97,59 @@ boundaries, and keep simple consumers independent of optional composition
|
|||||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||||
implemented surface against representative simple and complex shapes.
|
implemented surface against representative simple and complex shapes.
|
||||||
|
|
||||||
|
## Simplicity, Defaults, And Explicit Controls
|
||||||
|
|
||||||
|
These principles apply throughout ConfigKit: to configuration syntax, the public
|
||||||
|
Go API, defaults, and feature design. Evaluate their effects on both configuration
|
||||||
|
authors and consuming applications.
|
||||||
|
|
||||||
|
**Keep straightforward workflows straightforward.** Common configuration
|
||||||
|
workflows should require little configuration text and minimal API setup.
|
||||||
|
Support more demanding workflows through explicit, composable options, without
|
||||||
|
requiring simple consumers to understand or configure those options.
|
||||||
|
|
||||||
|
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
|
||||||
|
defaults that minimize required setup and accommodate valid configurations
|
||||||
|
without unnecessary restrictions. Provide selectable options for workflows that
|
||||||
|
need additional capabilities, narrower behavior, or stronger requirements.
|
||||||
|
|
||||||
|
Measure simplicity by what consumers must understand and maintain, including
|
||||||
|
configuration text and application-side plumbing. Concise syntax is useful, but
|
||||||
|
hidden inference and interacting meanings can make a shorter declaration harder
|
||||||
|
to understand. Prefer explicitness where it makes behavior more predictable.
|
||||||
|
A terse configuration format that requires substantial application-side
|
||||||
|
infrastructure does not satisfy the simplicity principle.
|
||||||
|
|
||||||
|
Define what permissiveness means for each behavior. Allowing an optional
|
||||||
|
contribution to be absent or accepting valid inputs without an unnecessary
|
||||||
|
filename restriction can reduce required setup. Invalid declarations, ambiguous
|
||||||
|
composition, and unreadable selected sources must still produce meaningful
|
||||||
|
errors under the applicable contracts. Permissiveness does not authorize silent
|
||||||
|
recovery from invalid supplied configuration or weaken configuration integrity.
|
||||||
|
|
||||||
|
Options should express coherent choices with understandable purposes and fit
|
||||||
|
ConfigKit's shared configuration model. Support powerful controls when they
|
||||||
|
serve concrete needs; these principles do not require a switch for every
|
||||||
|
implementation behavior or conceivable consumer preference.
|
||||||
|
|
||||||
|
Defaults require judgment when simplicity and permissiveness pull in different
|
||||||
|
directions. Broader discovery can accommodate more layouts while increasing the
|
||||||
|
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
|
||||||
|
not mechanically select the broadest behavior for every option. Changes to
|
||||||
|
existing defaults remain subject to compatibility review, and proposed behavior
|
||||||
|
remains in the roadmap until implemented.
|
||||||
|
|
||||||
## Responsibility Boundaries
|
## Responsibility Boundaries
|
||||||
|
|
||||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||||
resolution, validation, and inspection. Applications own their configuration
|
resolution, validation, and inspection. Applications own their configuration
|
||||||
types, field meanings, defaults, normalization, semantic validation, and schema
|
types, field meanings, defaults, normalization, semantic validation, and schema
|
||||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
version policy. Applications choose and order their main-file search conventions;
|
||||||
remote acquisition, runtime initialization, and resource checks.
|
ConfigKit provides explicitly selected search descriptions that resolve during
|
||||||
|
loading. Applications also own CLI wiring, remote acquisition, runtime
|
||||||
|
initialization, and resource checks. See the [configuration discovery
|
||||||
|
contract](../config.md#configuration-discovery) and [ADR
|
||||||
|
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
The engine applies each input's mapping destination within the existing source
|
The engine applies each input's mapping destination within the existing source
|
||||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||||
@@ -109,8 +161,18 @@ placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
|||||||
the import extension.
|
the import extension.
|
||||||
|
|
||||||
Library code must not depend on consuming applications or initialize their
|
Library code must not depend on consuming applications or initialize their
|
||||||
runtime resources. It must not perform implicit network access, discover
|
runtime resources. It must not perform implicit network access, implicitly
|
||||||
application-specific paths, or change process-global state during ordinary `Load`.
|
discover application-specific paths, or change process-global state during
|
||||||
|
ordinary `Load`.
|
||||||
|
Import discovery is available only through declarations in selected sources:
|
||||||
|
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||||
|
ConfigKit does not search conventional application paths unless the application
|
||||||
|
explicitly supplies a discovery description, and it never discovers a main
|
||||||
|
configuration file implicitly. Imports and sibling inheritance share
|
||||||
|
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||||
|
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||||
|
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||||
|
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
|
||||||
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
||||||
invoke it during startup, and it reports writes, skips and partial failures. It
|
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||||
must never run implicitly as part of configuration loading.
|
must never run implicitly as part of configuration loading.
|
||||||
@@ -133,6 +195,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
|||||||
the precise completion and ownership behavior. The proposed decision record is
|
the precise completion and ownership behavior. The proposed decision record is
|
||||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||||
|
|
||||||
|
### Accepted Processing Order
|
||||||
|
|
||||||
|
Complete source composition and precedence, including selected overlays, before
|
||||||
|
sibling inheritance. Then apply typed defaults and decode the configuration.
|
||||||
|
Apply explicit CLI overrides to that configuration and check their types before
|
||||||
|
running application resolution. Effective secret lookup and final validation
|
||||||
|
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
|
||||||
|
records this ordering.
|
||||||
|
|
||||||
|
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||||
|
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||||
|
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||||
|
must not introduce structural directives; earlier source, inheritance, and
|
||||||
|
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||||
|
Semantic validation assesses the final configuration after overrides and resolution.
|
||||||
|
|
||||||
|
Applications own flag parsing and domain resolution. ConfigKit owns override
|
||||||
|
application, validation, and source attribution. Resolution receives CLI values
|
||||||
|
with their explicit-presence and source information. Absence-based defaults
|
||||||
|
preserve explicit values; recorded normalization and derivation can still change
|
||||||
|
them. Typed defaults and application resolution retain their own assignment
|
||||||
|
semantics, independently of collection operations during source assembly.
|
||||||
|
|
||||||
## Public API And Dependency Direction
|
## Public API And Dependency Direction
|
||||||
|
|
||||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||||
@@ -161,41 +246,44 @@ framework on this library.
|
|||||||
|
|
||||||
## Configuration Integrity
|
## Configuration Integrity
|
||||||
|
|
||||||
Within a configuration tier, independent contributions use strict composition:
|
Within a configuration tier, independent contributions use strict composition.
|
||||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
Shared mappings combine recursively. Sequences with an applicable identity
|
||||||
are conflicts, including equal values, except that sequences at explicitly
|
combine distinct records and reject duplicate identities, including equal
|
||||||
registered paths append records with distinct present string identities. Between
|
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||||
precedence tiers, intentional overlay combines mappings recursively, retains
|
values. Conflicting identity or operation declarations among peers also fail.
|
||||||
omitted lower-priority children,
|
Traversal order establishes stable reporting and record order; it does not grant
|
||||||
and replaces other present values according to the existing overlay rules.
|
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
records the distinction between strict composition and intentional overrides.
|
||||||
reporting, not permission to override. The distinction and its rationale are
|
|
||||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
|
||||||
|
|
||||||
Sequence-item placement and sequence registration are explicit source and
|
|
||||||
definition controls; registration does not change precedence replacement. The
|
|
||||||
[configuration contract](../config.md#strict-document-composition) defines the
|
|
||||||
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
|
|
||||||
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
|
||||||
shows the implemented subset. This feature does not authorize arbitrary
|
|
||||||
configurable merge strategies.
|
|
||||||
|
|
||||||
The main document and its imports form a strictly composed tier. Explicit
|
The main document and its imports form a strictly composed tier. Explicit
|
||||||
underlay groups compose strictly within each group, then combine recursively
|
underlay groups compose strictly within each group, then combine from lowest to
|
||||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||||
determine its precedence role. Input-specific constraints, including CLI path
|
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
other present values retain the established replacement rules. Source acquisition
|
||||||
semantics do not require identical acquisition or lifetime behavior from every
|
kind does not determine precedence. Input-specific constraints, including version
|
||||||
source kind.
|
ownership and path protections, remain part of the configuration contract.
|
||||||
|
Consistent combination semantics do not require identical acquisition or lifetime
|
||||||
|
behavior from every source kind.
|
||||||
|
|
||||||
After source precedence is complete, built-in sibling `extends` relationships
|
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||||
resolve structurally in the same parser-independent representation, using the
|
parser-independent document representation. Preserve existing mapping-sibling
|
||||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
scopes and allow an identified sequence to provide a sibling scope for its
|
||||||
consumer registration mechanism. Enclosing relationships precede nested
|
records. A record may inherit from one parent in that sequence, selected by
|
||||||
destination-local resolution; typed defaults, application resolution, and
|
complete identity. The child supplies and keeps its own identity while inheriting
|
||||||
secret lookup retain their existing lifecycle roles. The [configuration
|
the parent's other settings. Parents remain ordinary records subject to normal
|
||||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
validation.
|
||||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
|
||||||
|
Use the shared dependency ordering and preserve assembled sequence order.
|
||||||
|
Resolve enclosing relationships before nested destination-local relationships.
|
||||||
|
Mappings combine recursively; sequences use the inheriting collection's policy.
|
||||||
|
Whole-record upsert replaces records during combination; explicit inheritance
|
||||||
|
uses the records that remain. Inheritance remains built-in and creates no
|
||||||
|
acquired source.
|
||||||
|
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
|
||||||
|
original scope and continuing ownership decisions, with sequence combination
|
||||||
|
and identified-sequence inheritance defined by
|
||||||
|
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||||
@@ -204,13 +292,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
|||||||
|
|
||||||
User-controlled precedence is application defaults, ordered underlays, composed
|
User-controlled precedence is application defaults, ordered underlays, composed
|
||||||
main input, selected overlay, then explicitly supplied CLI overrides.
|
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||||
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
|
This priority order is distinct from processing order: typed defaults are applied
|
||||||
Domain resolution follows these
|
at decoding, and application resolution follows CLI input under the lifecycle
|
||||||
inputs and must remain recorded, not become a hidden override channel.
|
rules above.
|
||||||
|
|
||||||
Preserve node-based composition until the effective input is ready for typed
|
Preserve node-based composition through source assembly and inheritance until
|
||||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
typed decoding. Preserve presence and source information through later CLI and
|
||||||
values must not be used to infer whether input was absent. Final configuration,
|
application-resolution changes as well. Ordinary Go zero values must not be used
|
||||||
|
to infer whether input was absent. Final configuration,
|
||||||
metadata, and effective inspection must agree, including application resolution
|
metadata, and effective inspection must agree, including application resolution
|
||||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
||||||
Safe historical values and an explanation renderer are part of the implemented contract,
|
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||||
@@ -227,6 +316,72 @@ comparison and disclosure behavior, the [component overview](../internal/overvie
|
|||||||
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||||
records the accepted decision.
|
records the accepted decision.
|
||||||
|
|
||||||
|
## Collection Identity And Combination
|
||||||
|
|
||||||
|
ConfigKit owns a common collection policy for imported records, inline sequences,
|
||||||
|
selected overlays, and sibling inheritance. Configuration authors may declare
|
||||||
|
identity and combination rules; applications may register authoritative identity
|
||||||
|
constraints. Configuration declarations must agree with application constraints.
|
||||||
|
Keep source placement explicit and separate from policy, retaining the placement
|
||||||
|
decision in [ADR 0014](../adr/0014-sequence-placement-and-composition.md).
|
||||||
|
[ADR 0017](../adr/0017-collection-sequence-policy.md) owns the rationale for
|
||||||
|
collection policy and its relationship to application authority.
|
||||||
|
|
||||||
|
Gather declarations for the relevant destination and source or item-local scope
|
||||||
|
before strict composition. Applicable identity from accumulated lower tiers must
|
||||||
|
be available when composing higher-tier peers. Higher-tier declarations cannot
|
||||||
|
repair invalid lower-tier composition. Preserve the distinction between source
|
||||||
|
acquisition, dependency-ready scheduling within scopes, and precedence order.
|
||||||
|
|
||||||
|
Strictly compose each tier's contributions before applying one operation for the
|
||||||
|
collection against its lower value. Support whole-sequence replacement,
|
||||||
|
duplicate-rejecting append, and whole-record replacement by identity (`upsert`).
|
||||||
|
Append and upsert require identity; do not infer it from record fields or position.
|
||||||
|
Duplicate identities among peers fail under every mode. Source precedence and
|
||||||
|
inheritance use the same operations and deterministic ordering rules. Whole-record
|
||||||
|
upsert replaces the matching record's fields together; recursive record merging
|
||||||
|
and unkeyed sequence concatenation are outside this operation model.
|
||||||
|
|
||||||
|
An explicit mode persists at its applicable destination until a higher explicit
|
||||||
|
mode changes it. With no explicit mode, effective identity selects upsert and
|
||||||
|
its absence selects whole-sequence replacement. Inferred defaults do not become
|
||||||
|
persistent instructions. The inheriting destination chooses its operation; copying
|
||||||
|
a parent collection does not automatically copy its mode to that destination.
|
||||||
|
|
||||||
|
Track YAML identity and explicit modes according to their separate lifetimes.
|
||||||
|
Actual whole-value replacement discards lower YAML identity while preserving
|
||||||
|
application constraints and any new identity declaration. Item-local policies
|
||||||
|
belong to their records: retaining a record retains its rules, and discarding it
|
||||||
|
discards those rules. Policies independently applicable at a destination remain
|
||||||
|
in effect. Inheritance can copy YAML identity subject to agreement and replacement
|
||||||
|
rules; application registrations continue to constrain their registered paths.
|
||||||
|
Numeric positions alone do not establish policy ownership.
|
||||||
|
|
||||||
|
Allow declarations without accompanying records. A key declaration validates a
|
||||||
|
retained sequence immediately or remains pending in its owning scope when no
|
||||||
|
sequence exists. A mode-only declaration affects later combinations without
|
||||||
|
recombining earlier tiers. Neither declaration alone clears a collection, and
|
||||||
|
declaring replace alone does not authorize changing a retained collection's
|
||||||
|
identity. These rules do not independently impose path-existence or shape
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
Enforce YAML identity during source composition, precedence, and identified
|
||||||
|
inheritance operations. Application registrations additionally constrain final
|
||||||
|
effective sequences after defaults, CLI overrides, and application resolution.
|
||||||
|
The application schema and validation own existence, nullability, and allowed
|
||||||
|
shape. The final identity guarantee covers actual sequences on successful load;
|
||||||
|
it does not cover every intermediate callback state or later caller mutations.
|
||||||
|
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||||
|
assignments, or application resolution.
|
||||||
|
|
||||||
|
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||||
|
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||||
|
available origin information without exposing identity values. The guarantee
|
||||||
|
requires no new mutation-history system or exact reconstruction of causes for
|
||||||
|
records supplied by defaults or callbacks. Exact identity equality, declaration
|
||||||
|
syntax, presence cases, and operation tables belong in the
|
||||||
|
[configuration contract](../config.md#strict-document-composition).
|
||||||
|
|
||||||
## Ownership And Confidentiality
|
## Ownership And Confidentiality
|
||||||
|
|
||||||
Returned configuration and metadata must not share mutable state across loads.
|
Returned configuration and metadata must not share mutable state across loads.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
|||||||
"$releasekit_bin" version
|
"$releasekit_bin" version
|
||||||
```
|
```
|
||||||
|
|
||||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
The module-selected executable is ReleaseKit v0.4.2. Use the built executable
|
||||||
for publication to preserve its exit status and publication-state diagnostics.
|
for publication to preserve its exit status and publication-state diagnostics.
|
||||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||||
|
|||||||
77
docs/releases/v0.10.0.md
Normal file
77
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ConfigKit v0.10.0
|
||||||
|
|
||||||
|
This minor release adds explicit, composable discovery of a configuration file.
|
||||||
|
Applications choose the search conventions and their order; ConfigKit selects one
|
||||||
|
file during loading and returns the final configuration with its normal metadata.
|
||||||
|
Optional searches that find no file share the expanded `NoSource()` lifecycle.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `SearchConfig`, immutable `Search` and `FilenameGroup` descriptions, and
|
||||||
|
search-wide requiredness through `SearchOptions`. Compose explicit `Paths`,
|
||||||
|
custom `SearchDirectory` descriptions, and built-in Unix system, user-config,
|
||||||
|
home, or working-directory presets with `Searches`. See the
|
||||||
|
[public declarations](../../discovery.go) and
|
||||||
|
[discovery contract](../config.md#configuration-discovery).
|
||||||
|
- Expand searches in supplied order, trying a directory's filename groups before
|
||||||
|
moving to the next directory. Named and generic groups provide conventional
|
||||||
|
YAML filenames; custom groups provide literal names. Directory searches
|
||||||
|
distinguish omitted filename selection from an explicitly empty selection.
|
||||||
|
Constructors perform no filesystem access, and each load observes current
|
||||||
|
directory bases and file availability.
|
||||||
|
- Select the first existing candidate. Access errors and failures while loading
|
||||||
|
the selected file terminate the load rather than trying another configuration.
|
||||||
|
Selected files retain physical source identity, placement, provenance, imports,
|
||||||
|
and relative-resource context. Discovery works with ordinary loading and
|
||||||
|
layered inputs, including underlays, without adding a precedence tier.
|
||||||
|
- Permit enabled imports with an absent main input: there are no main import
|
||||||
|
declarations to traverse, while actual underlay sources retain normal imports.
|
||||||
|
Support ordinary selected overlays with an absent main and explicit filesystem
|
||||||
|
context, including a root-only host context. Layered overlays retain their
|
||||||
|
independent `OverlayFilesystem` requirement. See
|
||||||
|
[single-source loading](../config.md#single-source-loading) and
|
||||||
|
[layered loading](../config.md#layered-loading).
|
||||||
|
- Preserve ordinary file-context behavior after discovery and include the
|
||||||
|
failing candidate's file identity in access diagnostics. Use Go's
|
||||||
|
platform-aware Windows component rules and native drive-root-relative path
|
||||||
|
resolution. Isolate user-config tests according to the host Go conventions.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Existing explicit source constructors and loading APIs remain available.
|
||||||
|
`File(path)` remains an exclusive selection that fails for a missing file;
|
||||||
|
discovery is activated only by explicitly supplying `SearchConfig`.
|
||||||
|
|
||||||
|
Searches are optional by default. Set `SearchOptions{Required: true}` when the
|
||||||
|
application must find a file. An exhausted optional search contributes no main
|
||||||
|
document or source record; application version checks, defaults, resolution,
|
||||||
|
and validation still run under their existing rules and may reject incomplete
|
||||||
|
configuration. A selected overlay is still required to exist.
|
||||||
|
|
||||||
|
The former `NoSource()` rejections of enabled imports and explicit filesystem
|
||||||
|
context are relaxed. An active ordinary overlay with no main file now loads
|
||||||
|
when explicit context is supplied. Applications that relied on those combinations
|
||||||
|
being rejected should review their option handling. Selected host files derive
|
||||||
|
their own base directory; do not supply an explicit `BaseDir` or native filesystem
|
||||||
|
context for a search that selects a host file.
|
||||||
|
|
||||||
|
Applications may replace their own fallback-loading loops with the new search
|
||||||
|
descriptions. The maintained [consumer examples](../consumers/README.md#explicit-main-file-discovery)
|
||||||
|
show simple preset selection and custom-first discovery alongside existing
|
||||||
|
configuration tiers. Composition, collection operations, inheritance, CLI
|
||||||
|
precedence, and the complete-on-return ownership model retain their existing
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. Release validation uses Go 1.26.10.
|
||||||
|
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer
|
||||||
|
tool remains ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated
|
||||||
|
targets; macOS remains best-effort. Publication follows the existing
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/docs/oss/attribution.md)
|
||||||
42
docs/releases/v0.10.1.md
Normal file
42
docs/releases/v0.10.1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ConfigKit v0.10.1
|
||||||
|
|
||||||
|
This patch release consolidates ConfigKit's internal loading machinery and
|
||||||
|
reduces repeated import bookkeeping. Public APIs and configuration semantics
|
||||||
|
remain unchanged from v0.10.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Consolidate ordinary and layered input acquisition, including import requests
|
||||||
|
and selected-overlay handling, while retaining their distinct context and
|
||||||
|
precedence rules. Remove unused private reverse conversions.
|
||||||
|
- Separate import acquisition, scoped composition, and evidence projection;
|
||||||
|
clarify inheritance preparation, combination, and finalization; and simplify
|
||||||
|
reflective projection and schema-field assembly responsibilities.
|
||||||
|
- Retain resolved filesystem facts from non-import validation for later passive
|
||||||
|
resource contexts and ordinary overlays. Remove duplicate absent-host context
|
||||||
|
derivation while preserving validation timing and handle cleanup.
|
||||||
|
- Record empty optional-import selections directly against their declaring
|
||||||
|
source, avoiding repeated searches through acquired sources. Index import
|
||||||
|
dependencies for scope-local edge preparation instead of scanning the whole
|
||||||
|
inclusion graph for every item scope. Stable dependency-ready ordering,
|
||||||
|
provenance, and source destinations are preserved.
|
||||||
|
- Expand regression coverage for import evidence ownership, scope boundaries,
|
||||||
|
and broad main/underlay item imports. Improve
|
||||||
|
[consumer loading guidance](../consumers/README.md) and internal documentation.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No consumer code or configuration migration is required. Composition, collection
|
||||||
|
operations, inheritance, discovery, CLI precedence, source-version checks, and
|
||||||
|
inspection retain their existing contracts.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. Dependencies and platform support are
|
||||||
|
unchanged. Publication follows the existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/docs/oss/attribution.md)
|
||||||
70
docs/releases/v0.10.2.md
Normal file
70
docs/releases/v0.10.2.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ConfigKit v0.10.2
|
||||||
|
|
||||||
|
This patch release fixes edge-case loading failures, resource cleanup, and limit
|
||||||
|
accounting, and simplifies ConfigKit's internal loading machinery. Public APIs
|
||||||
|
and documented configuration semantics remain unchanged from v0.10.1.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Terminate pointer-only recursive type traversal safely during decoding and
|
||||||
|
schema-path resolution, preserving nullable values and supported recursive
|
||||||
|
structures. Roll back failed dynamic descriptor construction so subsequent
|
||||||
|
operations do not reuse incomplete state or panic.
|
||||||
|
- Accept finite, non-cyclic slice views that share backing storage while
|
||||||
|
retaining cycle detection and detached output.
|
||||||
|
- Deactivate resolution, validation, and version-check contexts when callbacks
|
||||||
|
panic, and close library-owned import roots during panic unwinding. The
|
||||||
|
original application panic continues to propagate.
|
||||||
|
- Report directory enumeration and close failures instead of treating them as
|
||||||
|
optional absence, including failures reported as not-exist after a successful
|
||||||
|
open. Genuinely missing optional directories retain their existing behavior.
|
||||||
|
- Reuse completed symlink dependencies during host and native import discovery,
|
||||||
|
avoiding exponential repeated expansion of finite link graphs while retaining
|
||||||
|
cycle detection and containment checks.
|
||||||
|
- Check existing projection budgets before bulk container allocation and account
|
||||||
|
for the complete retained inspection-history graph, including replay and
|
||||||
|
separately copied inheritance evidence.
|
||||||
|
- Apply the documented append/upsert sequence requirements to non-sequence
|
||||||
|
contributions at retained collection-policy destinations.
|
||||||
|
|
||||||
|
## Internal improvements
|
||||||
|
|
||||||
|
- Index collection-policy retirement and prepare sparse incoming policy changes
|
||||||
|
instead of repeatedly scanning or copying unrelated retained policy.
|
||||||
|
- Reuse successful dynamic-type eligibility checks within an operation and index
|
||||||
|
tier-history lookups, compound identity restoration, and secret-failure source
|
||||||
|
attribution to avoid repeated scans.
|
||||||
|
- Remove unused snapshots, evidence, helpers, fields, and test-only production
|
||||||
|
paths. Clarify acquisition handoffs and separate collection preparation,
|
||||||
|
application, and finalization while sharing sequence-history construction.
|
||||||
|
- Separate patterned glob entry preparation from traversal. Consolidate engine
|
||||||
|
import defaults, snapshot them during load preflight, and construct placement
|
||||||
|
directly across the facade while keeping public types root-owned.
|
||||||
|
- Add regression coverage for the corrected failures, public historical paths,
|
||||||
|
defaults snapshots, and resource ownership. Strengthen fuzz detachment checks
|
||||||
|
and replace redundant or implementation-coupled assertions.
|
||||||
|
- Update release tooling from ReleaseKit v0.4.1 to v0.4.2.
|
||||||
|
|
||||||
|
These changes remove redundant work and improve scaling in the identified paths;
|
||||||
|
no end-to-end performance improvement has been quantified.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No consumer API or configuration-format migration is required. The
|
||||||
|
[configuration contract](../config.md) remains authoritative.
|
||||||
|
|
||||||
|
Loads that previously succeeded despite directory acquisition failures, invalid
|
||||||
|
append/upsert contributions, or incomplete retained-history accounting may now
|
||||||
|
return the documented structured errors. These corrections enforce existing
|
||||||
|
requirements and limits rather than introducing new ones.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. The YAML dependency and platform support
|
||||||
|
are unchanged. Publication follows the existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/docs/oss/attribution.md)
|
||||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
|||||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||||
validation toolchain. Retire completed planning and measurement records into
|
validation toolchain. Retire completed planning and measurement records into
|
||||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
|
||||||
|
|
||||||
## Compatibility and upgrade
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
|||||||
111
docs/releases/v0.9.0.md
Normal file
111
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# ConfigKit v0.9.0
|
||||||
|
|
||||||
|
This minor release expands import selection, adds author-declared collection
|
||||||
|
identity and operations, and supports inheritance between identified records.
|
||||||
|
It also moves CLI assignments after inheritance and typed decoding. These changes
|
||||||
|
include breaking API and configuration behavior changes from v0.8.0; review the
|
||||||
|
migration guidance below before upgrading.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Extend imports with named-file and directory lists, directory filename patterns,
|
||||||
|
exclusions, recursion and depth controls, and explicit hidden-file and symlink
|
||||||
|
discovery controls. Add `ImportDefaults` to ordinary and layered options, with
|
||||||
|
authored declarations taking precedence over application defaults. Imports are
|
||||||
|
optional by default. `SourceInfo.ImportSelections` exposes completed optional
|
||||||
|
declarations that selected no sources. See the
|
||||||
|
[import contract](../config.md#explicit-imports) and
|
||||||
|
[consumer guidance](../consumers/README.md#import-defaults-and-selection-inspection).
|
||||||
|
- Use shared dependency-ready ordering for import composition and sibling
|
||||||
|
inheritance. Dependencies complete before dependents, with deterministic
|
||||||
|
selection among currently eligible contributions. Source acquisition and
|
||||||
|
callback order remain separate from assembly order. See the
|
||||||
|
[ordering decision](../adr/0015-shared-dependency-ready-ordering.md).
|
||||||
|
- Add root `configkit.sequences` declarations for collection identity and
|
||||||
|
`replace`, `append`, or `upsert` operations, including in selected overlays.
|
||||||
|
Strict peers combine distinct identities and reject duplicates; precedence
|
||||||
|
and inheritance apply the destination's collection operation. Upsert replaces
|
||||||
|
a whole matching record at its retained position. Identity supports compound
|
||||||
|
field sets and scalar strings, numbers, booleans, and null. See the
|
||||||
|
[collection contract](../config.md#strict-document-composition).
|
||||||
|
- Add `Definition.SequenceIdentities`, exact `SequenceIdentityAt` registrations,
|
||||||
|
and dynamic `SequenceIdentityMatching` selectors. Application registrations
|
||||||
|
govern structural collection operations and check existing matching final
|
||||||
|
typed sequences, including values from defaults, CLI assignments, and
|
||||||
|
application resolution. YAML-only declarations govern structural operations
|
||||||
|
without adding that final-result check.
|
||||||
|
- Support record inheritance within an identified sequence using an `extends`
|
||||||
|
mapping containing the parent's complete identity. The child retains its own
|
||||||
|
identity, relationships use surviving assembled records, and output sequence
|
||||||
|
order is preserved. See [sibling inheritance](../config.md#sibling-inheritance)
|
||||||
|
and the maintained [sequence catalog](../consumers/README.md#identified-prompt-sequences).
|
||||||
|
- Apply explicit CLI overrides to the decoded configuration before application
|
||||||
|
resolution. Mappings retain omitted fields and their presence metadata;
|
||||||
|
sequences replace whole lists independently of collection operations. See the
|
||||||
|
[CLI contract](../config.md#explicit-cli-overrides).
|
||||||
|
- Correct native import alias and contained-symlink handling, source-version
|
||||||
|
path projection, and provenance/history through collection movement,
|
||||||
|
replacement, inheritance, and CLI assignment. Reduce repeated scans,
|
||||||
|
accumulated policy copying, and sparse-projection storage during construction.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
This pre-v1 minor release intentionally changes the v0.8.0 contracts:
|
||||||
|
|
||||||
|
1. **Migrate import requiredness.** The YAML `optional` field is removed and is
|
||||||
|
rejected. Replace `optional: true` with `required: false`; omission is equivalent
|
||||||
|
when the application does not default imports to required. Replace
|
||||||
|
`optional: false` with `required: true`. Imports that previously relied on
|
||||||
|
implicit requiredness now need `required: true`, or an application
|
||||||
|
`ImportDefaults.Required` default set to true. Review discovery filters:
|
||||||
|
`follow` and `hidden` default to true, and directory searches select all
|
||||||
|
filenames unless a pattern is supplied.
|
||||||
|
2. **Replace sequence registrations.** `SequenceComposition` and
|
||||||
|
`Definition.SequenceCompositions` are removed without compatibility aliases.
|
||||||
|
For a previous registration at `prompts` using `id`, use:
|
||||||
|
|
||||||
|
```go
|
||||||
|
rules.SequenceIdentities = []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These registrations now also enforce identity in final typed sequences. Fix
|
||||||
|
missing or duplicate identities introduced by defaults, CLI assignments, or
|
||||||
|
application resolution. Registration does not require a collection to exist
|
||||||
|
or determine its allowed shape; schema and application validation retain
|
||||||
|
those responsibilities.
|
||||||
|
3. **Review collection precedence and ordering.** An identified collection now
|
||||||
|
defaults to upsert across layers and inheritance when no explicit mode
|
||||||
|
applies, rather than unconditional whole-sequence replacement. Declare
|
||||||
|
`mode: replace` at the relevant destination where replacement is intended.
|
||||||
|
With append or upsert, `[]` contributes no records and does not clear the
|
||||||
|
collection; null is invalid. Use `mode: replace` with `[]` to clear it.
|
||||||
|
An explicit mode persists at its destination until changed; a parent's mode
|
||||||
|
does not transfer to a different inheriting destination. Unidentified
|
||||||
|
sequences retain strict peer conflicts and whole-value precedence
|
||||||
|
replacement. Dependency-ready assembly can change sequence positions and
|
||||||
|
diagnostic or history order compared with the former traversal order.
|
||||||
|
4. **Review CLI recovery and inheritance assumptions.** CLI overrides can no
|
||||||
|
longer repair values that fail earlier decoding, bypass source or inheritance
|
||||||
|
errors, or add or cancel inheritance relationships. Supply structural
|
||||||
|
directives in source configuration. Application resolution still receives
|
||||||
|
explicit CLI values and may record subsequent normalization or derivation.
|
||||||
|
5. **Update affected Go literals and diagnostic handling.** Use keyed literals
|
||||||
|
for expanded option and source-information structs. Collection validation adds
|
||||||
|
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||||
|
(`sequence.conflict`); update diagnostic handling where applicable.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.9.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||||
|
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
|
||||||
|
macOS remains best-effort. Publication follows the existing
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/docs/oss/attribution.md)
|
||||||
949
docs/roadmap/audit.md
Normal file
949
docs/roadmap/audit.md
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
# Consolidated Audit Review
|
||||||
|
|
||||||
|
Status: scope settled; 29 entries selected for implementation planning.
|
||||||
|
Two refactors are deferred to `future.md`. Implementation has not begun.
|
||||||
|
Consolidated: 2026-10-25. Baseline: `ecd5d5d`.
|
||||||
|
|
||||||
|
This document consolidates nine sequential audits for maintainer review. It owns
|
||||||
|
their open findings, supporting evidence, proposed remedies, and relationships.
|
||||||
|
The settled decisions select the [remediation roadmap's](roadmap.md) scope.
|
||||||
|
That document owns the combined target state and high-level dependency order;
|
||||||
|
this review owns detailed finding evidence and acceptance cases. Neither is a
|
||||||
|
staged implementation plan or a new set of release gates. The
|
||||||
|
[configuration contract](../config.md), public Go declarations, and
|
||||||
|
[architecture policy](../policy/architecture.md) continue to
|
||||||
|
own behavior and guarantees.
|
||||||
|
|
||||||
|
There are nine distinct reported defects: four from correctness, three from
|
||||||
|
resources, one from error handling, and one from security. Concurrency adds
|
||||||
|
evidence for an existing callback-lifetime defect. Other entries are cleanup,
|
||||||
|
efficiency, maintainability, or test improvements. Regression recommendations
|
||||||
|
are attached to the defects they protect rather than counted as additional
|
||||||
|
product fixes.
|
||||||
|
|
||||||
|
The 35 original numbered findings map to 31 review entries after deduplication
|
||||||
|
and integration of related test work. All findings remain unresolved; the
|
||||||
|
[settled scope decisions](#settled-scope-decisions) identify the 29 selected
|
||||||
|
entries. C03 and F07 have moved to `future.md`, with reference entries retained
|
||||||
|
here for traceability. All three scope questions are settled.
|
||||||
|
Their medium/low priorities retain the original assessments; those labels do not
|
||||||
|
imply severity certification or implementation order. IDs remain stable as work
|
||||||
|
is selected or completed.
|
||||||
|
|
||||||
|
## Evidence And Provenance
|
||||||
|
|
||||||
|
All nine audits were dated 2026-10-25. Their sequential commits changed only the
|
||||||
|
audit documents, so their production baseline is the same. This consolidation
|
||||||
|
does not independently reproduce the findings or claim new runtime evidence.
|
||||||
|
|
||||||
|
| Original report | Reviewed revision | Evidence contributed |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `correctness.md` | `47944d2` | Source review and standalone public-API reproductions of four defects. |
|
||||||
|
| `efficiency.md` | `5098b56` | Source-derived scaling cases and proposed algorithm changes; no benchmarks. |
|
||||||
|
| `complexity.md` | `320d0c5` | Responsibility/caller review, graph complexity estimates, and maintainability candidates. |
|
||||||
|
| `concurrency.md` | `a802734` | Ownership review, race checks, and corroboration of callback lifetime after panic. |
|
||||||
|
| `resources.md` | `0cca633` | Bounded allocation, retained-node, and host-handle diagnostics. |
|
||||||
|
| `error-handling.md` | `5925066` | Directory read/close fault injection through the public API. |
|
||||||
|
| `security.md` | `b30c9f1` | Trust-boundary review, bounded symlink operation counts, and a historical dependency spot-check. |
|
||||||
|
| `testing.md` | `eb5a82f` | Aggregate statement coverage, assertion review, and regression/test-cleanup recommendations. |
|
||||||
|
| `deadcode.md` | `b1c278b` | Type-aware reference inventory, graph/caller checks, and source review of private cleanup candidates. |
|
||||||
|
|
||||||
|
The full original reports, including exact probes, commands, source line
|
||||||
|
references, and validation limitations, are preserved at `ecd5d5d`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show ecd5d5d:docs/roadmap/correctness.md
|
||||||
|
git show ecd5d5d:docs/roadmap/security.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The [original-finding map](#original-finding-map) accounts for every numbered
|
||||||
|
finding, including duplicates and test recommendations integrated elsewhere.
|
||||||
|
Source links below identify implementation owners; historical line numbers are
|
||||||
|
left in the originals because later edits will move them.
|
||||||
|
|
||||||
|
### Recorded Validation And Its Limits
|
||||||
|
|
||||||
|
The audits report successful development checks, ordinary tests, full race tests,
|
||||||
|
`go vet`, and focused suites as specified in their individual records, using
|
||||||
|
Go 1.26.10 on Linux amd64. Concurrency additionally repeated six relevant engine
|
||||||
|
tests with race instrumentation, count 20, and CPUs 1, 4, and 8. Testing ran the
|
||||||
|
suite with shuffle enabled three times. These results did not detect the newly
|
||||||
|
reported defects and are not evidence that those defects have been fixed.
|
||||||
|
|
||||||
|
Testing recorded 771 ordinary test functions, three fuzz targets, and 29 executable
|
||||||
|
examples. Aggregate cross-package coverage was 90.3%: root 81.3%, engine 91.0%,
|
||||||
|
document 100%, YAML 89.9%, and maintained consumers 79.2%. Those figures locate
|
||||||
|
gaps; they are not targets or a measure of assertion strength. Only checked-in
|
||||||
|
fuzz seeds ran, not an extended campaign or mutation analysis.
|
||||||
|
|
||||||
|
Standalone diagnostics stayed outside the repository. Some potentially fatal
|
||||||
|
cases used disposable processes, small limits, or operation ceilings; larger
|
||||||
|
scaling examples were derived rather than executed. The dead-code inventory
|
||||||
|
considered Linux, Darwin, and Windows build configurations, but no audit reports
|
||||||
|
native macOS or Windows execution. Graph scores and reachability results were
|
||||||
|
triage aids, not proof that a function is unnecessary or a numerical refactoring
|
||||||
|
threshold. Cached analysis tooling was used outside the repository; no new
|
||||||
|
project tools or dependencies were introduced.
|
||||||
|
|
||||||
|
The security report's module-path advisory spot-check and successful module
|
||||||
|
integrity verification are historical evidence only. It recorded no listed
|
||||||
|
advisories for the selected YAML v4, ReleaseKit, or Goldmark v2 module paths, and
|
||||||
|
recorded the selected `x/mod` version as fixed for the two reports it found. It
|
||||||
|
did not perform complete toolchain/transitive reachability analysis or supply-
|
||||||
|
chain attestation; dedicated vulnerability, security, and secret scanners were
|
||||||
|
unavailable. This consolidation neither refreshes nor broadens that conclusion.
|
||||||
|
|
||||||
|
## Review Priorities And Dependencies
|
||||||
|
|
||||||
|
Recommended review order is reported defects with their regression coverage,
|
||||||
|
bounded cleanup, targeted efficiency changes, and then broader structural
|
||||||
|
refactoring. Small prerequisite cleanups can accompany a defect fix. The order
|
||||||
|
of the groups below is for navigation, not an execution sequence.
|
||||||
|
|
||||||
|
| Relationship | Consequence for scope selection |
|
||||||
|
| --- | --- |
|
||||||
|
| A02 and A05 | Make descriptor construction safe before reusing successful dynamic-type eligibility results. |
|
||||||
|
| B01 and B02 | Panic cleanup must close callback state and the owned host root independently. Neither fix substitutes for the other. |
|
||||||
|
| C01 and its filesystem tests | Strengthen error/handle assertions while adding failure-phase regressions, using one shared test owner. |
|
||||||
|
| C02 and deferred C03 | Complete link expansion remediation independently; reconsider glob readability extraction afterward through `future.md`. |
|
||||||
|
| D01, D02, and D03 | Remove the confirmed unused copies, then account for the complete retained graph. Removing one unused tree does not fix the remaining omissions. |
|
||||||
|
| E01, E02, and E03 | Address policy-state costs and responsibility boundaries together, preserving separate scaling and behavioral acceptance cases. |
|
||||||
|
| F01 and F02 | Removing the test-only acquisition mode does not by itself simplify active lifecycle handoffs. |
|
||||||
|
| F04, F09, and F12 | Replace useful facade ownership/query coverage before deleting disconnected or representation-coupled scaffolding. |
|
||||||
|
| F05 and deferred F07 | Remove unused placement bridges in this scope; reconsider wider transport simplification afterward through `future.md`. |
|
||||||
|
|
||||||
|
## A. Schema And Typed Values
|
||||||
|
|
||||||
|
### A01. Terminate Pointer-Only Recursive Descriptor Traversal
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: correctness 1; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** In [schema.go](../../internal/engine/schema.go), a legal
|
||||||
|
type such as `type Loop *Loop` produces a pointer descriptor whose element is
|
||||||
|
itself. [decode.go](../../internal/engine/decode.go) unwraps that descriptor
|
||||||
|
without consuming a non-null input node; `resolveSchemaPath` likewise assumes
|
||||||
|
pointer traversal eventually reaches another kind. A public load with a `Loop`
|
||||||
|
field and `value: {}` overflowed the stack in a process with a small stack limit.
|
||||||
|
`value: null` succeeded, but resolving a normalization path for that field hung
|
||||||
|
until the diagnostic process was stopped. The [schema
|
||||||
|
contract](../config.md#application-schema-binding) permits recursive types with
|
||||||
|
acyclic values; the inline-field restriction is not a blanket pointer restriction.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Detect repeated descriptors while unwrapping
|
||||||
|
pointer-only chains in decoding and schema-path resolution, and review analogous
|
||||||
|
helpers. Incompatible non-null input must fail safely; path traversal must
|
||||||
|
terminate and reject unaddressable descendants. Preserve supported nil/null
|
||||||
|
values and ordinary recursive structs. Do not add a blanket schema rejection or
|
||||||
|
arbitrary depth cap. Add bounded regression coverage for both decoding and helper
|
||||||
|
path resolution, keeping fatal/hanging probes out of the default test process.
|
||||||
|
|
||||||
|
### A02. Make Failed Dynamic Descriptor Construction Transactional
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: correctness 2; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `dynamicDescriptor` in
|
||||||
|
[value_operation.go](../../internal/engine/value_operation.go) passes the load's
|
||||||
|
descriptor cache to `schemaBuilder`. Failed construction leaves cached container
|
||||||
|
placeholders. For a dynamic struct containing an unsupported channel field, two
|
||||||
|
`Normalize` calls in one resolver, first with a typed nil pointer and then a
|
||||||
|
non-nil pointer to that type, reproduced a nil-pointer panic on the second call.
|
||||||
|
The first call reported a diagnostic but left a reusable partial descriptor.
|
||||||
|
The application callback itself did not panic.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Stage new descriptors and commit only on
|
||||||
|
successful construction, or roll back all additions from a failed build. Include
|
||||||
|
recursive references among newly built descriptors; clearing only the immediate
|
||||||
|
failed container or adding a projection nil guard is insufficient. Preserve
|
||||||
|
previously valid descriptors, load-local ownership, and safe path-specific
|
||||||
|
diagnostics under [typed projection](../config.md#typed-projection-and-ownership)
|
||||||
|
and [resolution](../config.md#recorded-application-resolution). Test repeated
|
||||||
|
operations after a failed build in the same load. Coordinate A05 only after this
|
||||||
|
failure-state boundary is sound.
|
||||||
|
|
||||||
|
### A03. Distinguish Acyclic Slice Views In Cycle Detection
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: correctness 3; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `valueVisit` / `visitFor` in
|
||||||
|
[value_operation.go](../../internal/engine/value_operation.go) identify slices by
|
||||||
|
type, kind, and starting pointer, omitting length. The finite value built by
|
||||||
|
`shared := []any{"leaf", nil}; shared[1] = shared[:1]` was rejected through mapping
|
||||||
|
sources, typed defaults, and CLI overrides, while an equivalent independently
|
||||||
|
allocated value succeeded. The nested view contains only the leaf, not its
|
||||||
|
enclosing view. The same helper serves projection/cloning and
|
||||||
|
[value.go](../../internal/engine/value.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Include visible slice length in active-visit
|
||||||
|
identity, or use an equivalent distinction. Preserve rejection of actual self
|
||||||
|
and mutual cycles, nil/empty behavior, and detached output; preserving alias
|
||||||
|
identity is not required. Regressions should cover the shared source, override,
|
||||||
|
projection, and cloning paths under the existing supported-acyclic-value contract,
|
||||||
|
alongside true-cycle rejection.
|
||||||
|
|
||||||
|
### A04. Enforce Existing Node Budgets Before Container Reservations
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: resources 1; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `projectSequence` and `projectMap` in
|
||||||
|
[projection.go](../../internal/engine/projection.go) reserve space for the full
|
||||||
|
container, and maps collect all keys, before child charging in
|
||||||
|
[value_operation.go](../../internal/engine/value_operation.go). Defaults containing
|
||||||
|
a maximum-length slice of zero-sized structs caused a `makeslice: cap out of range`
|
||||||
|
panic instead of `schema.projection_limit`; that diagnostic did not allocate a
|
||||||
|
huge input backing array. Less extreme cases can allocate excessive temporary
|
||||||
|
memory before rejection. Application resolution can reach these paths too.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Check the known minimum node cost against the
|
||||||
|
remaining existing allowance with overflow-safe arithmetic, or bound initial
|
||||||
|
capacity and grow as charging succeeds. Review map reservation/key collection as
|
||||||
|
well as sequences. Preserve node/depth semantics, inline handling, and useful
|
||||||
|
diagnostic paths. Use bounded regressions, including small controlled allowances,
|
||||||
|
without enormous allocations. This enforces an existing budget, not a new length
|
||||||
|
cap or universal heap-size guarantee.
|
||||||
|
|
||||||
|
### A05. Reuse Successful Dynamic-Type Eligibility Checks Locally
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: low. Source: efficiency 6.
|
||||||
|
|
||||||
|
**Problem and evidence.** Each dynamic value in projection/cloning invokes
|
||||||
|
`descriptorContainsSecret` with a new visited map, even when its descriptor is
|
||||||
|
cached. N values of one concrete type with descriptor-graph size S therefore cost
|
||||||
|
O(N × S) eligibility work. N interface values containing nil pointers to one wide
|
||||||
|
supported struct can produce only N null values while repeating the full type
|
||||||
|
walk. This is a source-derived bound, not measured runtime.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** After A02, consider remembering successfully
|
||||||
|
approved concrete descriptors within `valueOperation`, reducing repeated-type
|
||||||
|
eligibility work to O(S + N). Cache only completed successful eligibility checks,
|
||||||
|
not traversal-local visits, partial descriptors, or path-bearing failures.
|
||||||
|
Preserve rejection of dynamic secret-reference types, including nil pointers and
|
||||||
|
recursive descriptor cases. This does not select cross-load schema caching or a
|
||||||
|
clone traversal redesign.
|
||||||
|
|
||||||
|
## B. Callback And Filesystem Lifetime
|
||||||
|
|
||||||
|
### B01. Deactivate Callback Contexts During Panic Unwinding
|
||||||
|
|
||||||
|
Type: reported defect. Priority: low. Sources: correctness 4, concurrency 1;
|
||||||
|
testing 1; corroborated by error-handling and security reviews.
|
||||||
|
|
||||||
|
**Problem and evidence.** `runResolution` in
|
||||||
|
[resolution.go](../../internal/engine/resolution.go) and `runValidation` /
|
||||||
|
`reportVersion` in [validation.go](../../internal/engine/validation.go) deactivate
|
||||||
|
contexts only after normal callback return. Public probes retained the context,
|
||||||
|
panicked, recovered outside `Load`, and then successfully queried resolution or
|
||||||
|
wrote validation/version diagnostics. The concurrency audit reproduced this
|
||||||
|
with race instrumentation without reporting a data race. The defect violates
|
||||||
|
callback lifetime, not a newly supported concurrent-use scenario.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Defer deactivation within each callback
|
||||||
|
invocation, closing immediately at normal return and on unwinding, before later
|
||||||
|
projection or metadata work. Preserve the original panic and prohibit retained
|
||||||
|
context use through the established closed-context behavior. Test all three
|
||||||
|
callback kinds with external recovery. No recovery service, lock, atomic flag,
|
||||||
|
or broader callback thread-safety guarantee is needed. B02 separately owns root
|
||||||
|
handle cleanup.
|
||||||
|
|
||||||
|
### B02. Close The Owned Import Root When A Version Callback Panics
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: resources 3; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** Host import acquisition in
|
||||||
|
[import_acquisition.go](../../internal/engine/import_acquisition.go) opens an
|
||||||
|
`os.Root` through [filesystem_access.go](../../internal/engine/filesystem_access.go)
|
||||||
|
but relies on explicit ordinary-path closes. An imported version-check panic
|
||||||
|
bypasses them. A disposable Linux probe disabled GC, recovered outside `Load`,
|
||||||
|
and observed one remaining root descriptor through `/proc/self/fd`; the normal
|
||||||
|
control returned to zero. The imported file's own deferred close still ran.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Install deferred fallback cleanup at the
|
||||||
|
owning boundary, preserving explicit normal close timing and diagnostics, as
|
||||||
|
selected-overlay acquisition already does. Avoid double-close confusion, leave
|
||||||
|
borrowed filesystems untouched, and propagate the original panic. A regression
|
||||||
|
must observe the owned host root, not merely a native file-close counter, and
|
||||||
|
document platform prerequisites. Context deactivation in B01 is a separate
|
||||||
|
requirement. This restores load-scope cleanup without adding panic recovery.
|
||||||
|
|
||||||
|
## C. Import Selection And Link Resolution
|
||||||
|
|
||||||
|
### C01. Preserve Directory Failure Phase And Strengthen Its Tests
|
||||||
|
|
||||||
|
Type: reported defect with test improvements. Priority: medium.
|
||||||
|
Sources: error-handling 1, testing 3.
|
||||||
|
|
||||||
|
**Problem and evidence.** `filesystemAccess.readDirectory` in
|
||||||
|
[filesystem_access.go](../../internal/engine/filesystem_access.go) returns open,
|
||||||
|
read, and close errors through one unqualified error channel.
|
||||||
|
[import_selection.go](../../internal/engine/import_selection.go) and
|
||||||
|
[glob.go](../../internal/engine/glob.go) treat any `fs.ErrNotExist` as absence.
|
||||||
|
A borrowed filesystem probe selected a failing directory plus a good directory,
|
||||||
|
using both `dir` and `glob` with `required: true`. Injecting `fs.ErrNotExist`
|
||||||
|
alongside entries from `ReadDir`, or from `Close`, let loads succeed with the bad
|
||||||
|
directory's contribution omitted. Permission-classified controls failed. All
|
||||||
|
eight selector/phase/error cases ran. This violates the existing distinction
|
||||||
|
between optional absence and failed acquisition.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Preserve missing-on-open versus post-open
|
||||||
|
failure, for example with a small private failure record analogous to regular
|
||||||
|
file acquisition. Keep genuine missing-target rules, entry budgets, first-error
|
||||||
|
precedence, borrowed filesystem ownership, and safe existing traversal diagnostics.
|
||||||
|
Review both batched `fs.ReadDirFile` and standard fallback paths. A successful
|
||||||
|
second directory must not hide enumeration/close failure; raw backing error text
|
||||||
|
must not leak. No retries or filesystem transaction are proposed.
|
||||||
|
|
||||||
|
**Test improvements.** The shared fixture in
|
||||||
|
[native_filesystem_test.go](../../internal/engine/native_filesystem_test.go) and
|
||||||
|
[resource_context_test.go](../../internal/engine/resource_context_test.go) currently
|
||||||
|
checks aggregate closes and often only non-nil errors. One leaked handle and
|
||||||
|
one double-close can cancel out. Its canary check examines the terse error
|
||||||
|
summary, not structured diagnostics. Track per-handle closure, assert nil result,
|
||||||
|
diagnostic code and source, and inspect diagnostic messages/rendering for canaries.
|
||||||
|
Add entries/data-plus-error and close-phase cases through narrow failure seams.
|
||||||
|
Preserve intentionally ignored read-only regular-file close errors; this does
|
||||||
|
not impose a universal close-error policy.
|
||||||
|
|
||||||
|
### C02. Avoid Reexpanding Completed Finite Symlink Dependencies
|
||||||
|
|
||||||
|
Type: reported availability defect. Priority: medium. Source: security 1;
|
||||||
|
testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** Host/native resolvers in
|
||||||
|
[filesystem_links.go](../../internal/engine/filesystem_links.go) track active links
|
||||||
|
but reuse no completed resolution. A finite contained graph `s00 -> .`,
|
||||||
|
`s01 -> s00/s00`, `s02 -> s01/s01`, and so on repeats completed dependencies.
|
||||||
|
Its work follows T(N) = 2T(N-1) + O(1), despite linear graph size. Directory
|
||||||
|
selection resolves discovered links before deciding on recursive descent, so
|
||||||
|
`recursive: false` does not avoid it. Existing source/entry/node/import-depth
|
||||||
|
budgets do not account for the repeated expansion.
|
||||||
|
|
||||||
|
A real-link fixture exposed through a native `fs.ReadLinkFS` wrapper recorded
|
||||||
|
32, 128, 512, 2,048, and 8,192 `Lstat` calls for 3, 5, 7, 9, and 11 entries;
|
||||||
|
corresponding `ReadLink` counts were 11, 57, 247, 1,013, and 4,083. Thirteen
|
||||||
|
entries reached a diagnostic ceiling of 12,000 `Lstat` calls. With following
|
||||||
|
disabled, that fixture used 17 `Lstat` calls and no `ReadLink`. The host resolver's
|
||||||
|
matching logic was reviewed, not separately operation-count tested.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Reuse fully resolved targets within the
|
||||||
|
resolution operation, keyed for the rooted namespace and full link path, or use
|
||||||
|
equivalent dependency evaluation. Retain active-path cycle detection, containment,
|
||||||
|
finite reuse, relative/absolute host semantics, and the stable-input assumption.
|
||||||
|
A global visited-means-cycle rule would incorrectly reject valid inputs. Cover
|
||||||
|
both resolvers and directory/glob entry points with small fixtures and bounded
|
||||||
|
operation observation, not timing thresholds or exhaustion tests. No arbitrary
|
||||||
|
link cap, public cache, or default-follow change is selected.
|
||||||
|
|
||||||
|
The availability risk requires influence over imported filesystem data; it is
|
||||||
|
not an anonymous remote vulnerability by itself. No concurrent mutation or
|
||||||
|
outside-root target is needed, and accidental graphs can cause the same work.
|
||||||
|
|
||||||
|
### C03. Separate Glob Entry Preparation From Traversal
|
||||||
|
|
||||||
|
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
|
||||||
|
belong to [future work](future.md#1-separate-glob-entry-preparation-from-traversal),
|
||||||
|
for consideration after this roadmap's selected work is complete.
|
||||||
|
|
||||||
|
## D. Retained History And Unused Evidence
|
||||||
|
|
||||||
|
### D01. Account For The Complete Published History Graph
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: resources 2; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** The checkpoint in [load.go](../../internal/engine/load.go)
|
||||||
|
and [history.go](../../internal/engine/history.go) counts a different root set
|
||||||
|
from [load_result.go](../../internal/engine/load_result.go) publication. Separately
|
||||||
|
cloned post-CLI input, `structuralInput`, `preCLI`, and later inheritance copies
|
||||||
|
are not all represented. A read-only diagnostic of a small successful load with
|
||||||
|
a no-op resolver counted 14 distinct nodes before including the two published
|
||||||
|
replay roots and 22 with them. Source inspection found that the checkpoint uses
|
||||||
|
a different, same-sized decoded-final instance and also counts 14 in this case.
|
||||||
|
Generalized to N scalar items, the reported counts were 3N + 8 versus 5N + 12;
|
||||||
|
800,000 items would pass the checkpoint yet exceed the existing 4,000,000-node
|
||||||
|
history allowance. That large case was derived, not allocated or executed.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Use pointer-aware accounting for every document
|
||||||
|
root actually retained at publication, including replay and copied inheritance
|
||||||
|
history, counting shared nodes once. Where practical, reserve/charge copies before
|
||||||
|
allocation. Coordinate removal of unused trees under D03 and recalculate the
|
||||||
|
retained set afterward; that removal alone does not establish complete accounting.
|
||||||
|
Reuse already-owned immutable snapshots only where equivalence and ownership
|
||||||
|
are demonstrated. This need not redesign public metadata copying.
|
||||||
|
|
||||||
|
Use a small controlled allowance to test publication integration and the existing
|
||||||
|
`explain.limit_exceeded` outcome, as well as count-once sharing. Helper-only tests
|
||||||
|
cannot detect roots omitted by callers. Preserve [historical
|
||||||
|
inspection](../config.md#historical-value-explanation), detached ownership, and
|
||||||
|
the existing quota; no smaller limit or universal heap-size bound is proposed.
|
||||||
|
|
||||||
|
### D02. Remove The Unconsumed Override-Result Pre-CLI Copy
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Source: deadcode 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `decodedOverrideResult.preCLI` in
|
||||||
|
[override_decode.go](../../internal/engine/override_decode.go) clones the entire
|
||||||
|
decoded final tree even without CLI assignments. Its readers are private tests;
|
||||||
|
production independently clones `before.final` into the active context in
|
||||||
|
[override_context.go](../../internal/engine/override_context.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove that result member, its clone, and
|
||||||
|
assertions solely about it. Preserve the independently owned pre-CLI context
|
||||||
|
that actually feeds resolution, publication, and explanation. Retain behavioral
|
||||||
|
CLI/history/ownership coverage. The change removes a redundant traversal, not
|
||||||
|
the active replay snapshot or its detachment boundary.
|
||||||
|
|
||||||
|
### D03. Remove The Unqueried Published Structural-Input Copy
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Source: deadcode 2.
|
||||||
|
|
||||||
|
**Problem and evidence.** `Inspection.structuralInput` in
|
||||||
|
[load.go](../../internal/engine/load.go) is cloned and indexed at publication,
|
||||||
|
but has no production reader. [explain.go](../../internal/engine/explain.go)
|
||||||
|
uses source/inheritance replay, `preCLI`, and `input`. The immutable lookup is
|
||||||
|
addressed by supplied node pointers; indexing this otherwise unused root does
|
||||||
|
not make it useful and keeps its mappings alive.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove the inspection member, final clone,
|
||||||
|
and its `retainedTrees` inclusion. Keep earlier `resolved.structuralInput` uses
|
||||||
|
for path finalization and sensitivity. Check explanation, historical paths,
|
||||||
|
redaction, and ownership through active outputs. Coordinate D01 for all remaining
|
||||||
|
retained roots; do not infer that structural evidence is globally unused.
|
||||||
|
|
||||||
|
### D04. Stop Constructing Unconsumed Combination Evidence
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Source: deadcode 5.
|
||||||
|
|
||||||
|
**Problem and evidence.** `sequenceCombinationResult.discardedLower` in
|
||||||
|
[sequence_combine.go](../../internal/engine/sequence_combine.go) allocates a list
|
||||||
|
of all replaced lower indexes with no production reader. Three tests inspect it.
|
||||||
|
`inheritanceReplacement.path`, `previous`, and `effective` in
|
||||||
|
[inheritance.go](../../internal/engine/inheritance.go) are also unread, but their
|
||||||
|
writers perform path/origin copies and displaced-subtree walks. Active inheritance
|
||||||
|
history uses operations and projected changes elsewhere. Replacement `before`
|
||||||
|
and `after` trees still feed node accounting.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove only unread members and work solely
|
||||||
|
feeding them. Keep replacement trees, item projections, replacement mappings,
|
||||||
|
`appendedHigher`, active provenance/history, and node accounting. Keep
|
||||||
|
`sequenceIndexes` for append and `displacedOrigins` for overlay callers. Replace
|
||||||
|
private unused-evidence assertions with existing behavior-level replacement,
|
||||||
|
removed-descendant, and history protection where necessary. Coordinate D01 and
|
||||||
|
E03 without deleting active evidence merely because another representation exists.
|
||||||
|
|
||||||
|
## E. Collection Processing And Efficiency
|
||||||
|
|
||||||
|
All complexity estimates here describe identified internal work with bounded-
|
||||||
|
depth examples and expected hash lookup costs. Actual path processing, copied
|
||||||
|
payload, and expanded output remain costs; no end-to-end complexity or timing
|
||||||
|
guarantee is proposed.
|
||||||
|
|
||||||
|
### E01. Avoid Scanning Every Policy For Each Replacement
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `retireCollectionPoliciesAtOrBelow` in
|
||||||
|
[collection_policy_state.go](../../internal/engine/collection_policy_state.go)
|
||||||
|
scans all P policies for each of Q ordinary replacements in
|
||||||
|
[overlay.go](../../internal/engine/overlay.go), even if the changed scalars are
|
||||||
|
unrelated to every collection. P = Q = N gives O(N²) visits for linear-sized
|
||||||
|
input and output within one tier.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider an operation-local semantic-path
|
||||||
|
index with descendant queries, maintained as policies change, or batch retirement
|
||||||
|
where timing remains equivalent. The disjoint case can approach O(P + Q), with
|
||||||
|
additional work charged to affected entries/path lengths. Preserve explicit-mode
|
||||||
|
persistence, record ownership, and the moment old identity retires; delayed
|
||||||
|
retirement must not clear newly established higher-tier policy. Evaluate existing
|
||||||
|
inheritance indexing without requiring identical interfaces. Retain a distinct
|
||||||
|
disjoint-replacement case even if implementation is coordinated with E02/E03.
|
||||||
|
|
||||||
|
### E02. Avoid Recopying Unrelated Policy Across Small Tiers
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 2.
|
||||||
|
|
||||||
|
**Problem and evidence.** `lowerCollectionPoliciesForClaims` in
|
||||||
|
[collection_retirement.go](../../internal/engine/collection_retirement.go) clones
|
||||||
|
and scans lower policy, and `resolveStrictCollectionPolicies` in
|
||||||
|
[compose.go](../../internal/engine/compose.go) clones it again. Overlay preparation
|
||||||
|
repeats full-state work and checks retained destinations. P initial policies
|
||||||
|
followed by T scalar-only tiers cost O(T × P) work/allocation although only O(P + T)
|
||||||
|
material exists. Copies include record order and nested record policy; these
|
||||||
|
are transient private copies, not independently published snapshots.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Separate read-only lower-policy lookup from
|
||||||
|
owned current-tier changes, localize work to incoming claims/declarations, and
|
||||||
|
apply changes to the assembly owner. Keep copies at actual mutable ownership
|
||||||
|
boundaries. Removing the immediate duplicate copy helps but does not alone fix
|
||||||
|
O(T × P) growth. Preserve pending declarations, replacement eligibility, and
|
||||||
|
independent nested record policies. This case remains distinct from E01 and the
|
||||||
|
deferred public metadata-copy experiment.
|
||||||
|
|
||||||
|
### E03. Clarify Policy Preparation And Merge Evidence Application
|
||||||
|
|
||||||
|
Type: selected refactor. Priority: medium. Source: complexity 3.
|
||||||
|
|
||||||
|
**Problem and evidence.** [overlay.go](../../internal/engine/overlay.go) combines
|
||||||
|
claim/declaration preparation, operation resolution, callbacks, diagnostics,
|
||||||
|
origins/history, retirement, record policy rebuilding, and declaration-only
|
||||||
|
finalization. Overlay and [inheritance](../../internal/engine/inheritance_collection.go)
|
||||||
|
separately translate sequence results into history descriptors and reconstruct
|
||||||
|
record order. `combineSequence` already owns shared value combination.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider explicit preparation, application,
|
||||||
|
and finalization operations with a small result/owner for evidence and diagnostics.
|
||||||
|
Share straightforward history-descriptor construction; assess projected-record
|
||||||
|
selection sharing while keeping precedence and inheritance policy ownership
|
||||||
|
explicit. Do not unify distinct origin or mode-lifetime rules behind a generic
|
||||||
|
strategy framework. Coordinate E01/E02 so extraction does not preserve avoidable
|
||||||
|
full-table work. Preserve pending declarations, replacement ordering, retained
|
||||||
|
versus incoming record owners, and stable history. D04 can remove unused evidence
|
||||||
|
without requiring this broader refactor.
|
||||||
|
|
||||||
|
### E04. Index Tier-History Projection Lookups
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 3.
|
||||||
|
|
||||||
|
**Problem and evidence.** `projectTierHistoryApplications` in
|
||||||
|
[sequence_history_projection.go](../../internal/engine/sequence_history_projection.go)
|
||||||
|
calls unindexed `documentAtPath` for each mapped sequence. N sibling collections
|
||||||
|
selected by one wildcard identity registration incur O(N²) mapping comparisons
|
||||||
|
while projecting linear-sized source/history material.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Lazily prepare one read-only `documentLookup`
|
||||||
|
per original application value with mapped contributions, or collect needed nodes
|
||||||
|
in one traversal. With V nodes, S contributions, depth D, and I projected items,
|
||||||
|
that portion can be O(V + S × D + I). Preserve authored coordinates, sparse item
|
||||||
|
projections, replacement evidence, and independent output storage. This is load-
|
||||||
|
time coordinate projection, not branch-local explanation replay.
|
||||||
|
|
||||||
|
### E05. Restore Compound Identity Without Repeated Mapping Scans
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 4.
|
||||||
|
|
||||||
|
**Problem and evidence.** `preserveInheritanceIdentityFields` in
|
||||||
|
[inheritance_scope.go](../../internal/engine/inheritance_scope.go) searches child
|
||||||
|
and merged mappings for each identity field. K fields and C/M child/merged
|
||||||
|
entries cost O(K × (C + M)) comparisons plus required clones. A wide compound
|
||||||
|
identity can make this quadratic without expanding record output.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Index merged positions and walk child entries
|
||||||
|
against an identity-field set, or use equivalent local mapping indexes, for
|
||||||
|
O(K + C + M) lookup work plus copies. Preserve child-owned identity values and
|
||||||
|
locations, mapping order, and detached nodes. Do not change identity equality or
|
||||||
|
parent selection. Removing the restoration step instead requires a separate
|
||||||
|
equivalence review; apparent duplication alone is insufficient.
|
||||||
|
|
||||||
|
## F. Remaining Maintenance And Test Improvements
|
||||||
|
|
||||||
|
### F01. Remove The Test-Only Eager Acquisition Mode
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Sources: complexity 1, deadcode 8.
|
||||||
|
|
||||||
|
**Problem and evidence.** Both production constructors of the request declared in
|
||||||
|
[acquisition.go](../../internal/engine/acquisition.go) set
|
||||||
|
`inputAcquisitionRequest.deferAssembly` to true. Only private
|
||||||
|
test helpers select the eager composition branch in
|
||||||
|
[import_acquisition.go](../../internal/engine/import_acquisition.go). Acquisition
|
||||||
|
maintains two return protocols and tests can bypass the production phase handoff.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Return retained scopes consistently and remove
|
||||||
|
the unused production mode. Tests needing composition should invoke the active
|
||||||
|
composition phase through a small test-only helper; acquisition-only tests should
|
||||||
|
remain acquisition-only. Preserve closure, pending-item reservation, accounting,
|
||||||
|
diagnostics, and meaningful composition coverage. This does not retire public
|
||||||
|
loading workflows or imply that every nil-state/directive context is unused.
|
||||||
|
|
||||||
|
### F02. Clarify Active Acquisition And Coordinator Handoffs
|
||||||
|
|
||||||
|
Type: selected refactor. Priority: medium. Source: complexity 2.
|
||||||
|
|
||||||
|
**Problem and evidence.** `acquireInput` in
|
||||||
|
[acquisition.go](../../internal/engine/acquisition.go) combines discovery, parsing,
|
||||||
|
directive/version branches, placement/accounting, dispatch, and passive contexts.
|
||||||
|
Version branches repeat source-ID/failure/result handling, and layered processing
|
||||||
|
extracts and restores directives. [load.go](../../internal/engine/load.go) contains
|
||||||
|
a substantial ordinary-load context fallback; [load_request.go](../../internal/engine/load_request.go)
|
||||||
|
repeats tier declaration/application/projection handoffs. Parsed, local, composed,
|
||||||
|
inherited, and version-path representations are difficult to follow together.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider cohesive parsed-input directive/version
|
||||||
|
processing and standalone placement/registration operations, plus explicit context,
|
||||||
|
structural-expansion, and repeated tier handoffs. Reuse existing state/results;
|
||||||
|
helpers that merely share the same mutable surrounding locals do not resolve
|
||||||
|
ownership. Preserve visible phase order, ordinary/layered overlay differences,
|
||||||
|
default/enabled version timing, warnings, exactly-once callbacks, and load-wide
|
||||||
|
versus tier-local state. F01 removes a different, inactive protocol; neither task
|
||||||
|
requires a new context hierarchy or public request model.
|
||||||
|
|
||||||
|
### F03. Retire The Parallel Test-Only Import Directive Parser
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 7.
|
||||||
|
|
||||||
|
**Problem and evidence.** `parseImportSpecificationDirective` in
|
||||||
|
[import_spec.go](../../internal/engine/import_spec.go) is called only by the
|
||||||
|
`parseImportSpecsYAML` test fixture. It duplicates mapping/field dispatch already
|
||||||
|
owned by `parseRootDirectiveBlock` in [directives.go](../../internal/engine/directives.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Use the active parser with the appropriate
|
||||||
|
context, or parse the declaration list directly when that is the tested boundary,
|
||||||
|
then remove the obsolete implementation. Preserve malformed-declaration and
|
||||||
|
selector coverage. Keep the active imports-only context used in layered version
|
||||||
|
processing; thin test helpers delegating to production are not inherently dead.
|
||||||
|
|
||||||
|
### F04. Remove The Disconnected Root Record-Clone Family
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 3; related testing 6.
|
||||||
|
|
||||||
|
**Problem and evidence.** The private family in root [records.go](../../records.go)
|
||||||
|
has no production facade entry: `cloneLocation`, `cloneLocations`, `cloneSourceInfo`,
|
||||||
|
`cloneImportSelections`, `cloneSourceVersionCheck`, `cloneSourceInfos`, `cloneOrigin`,
|
||||||
|
`cloneFieldInfo`, `cloneChange`, `cloneDiagnostic`, and `cloneDiagnostics`.
|
||||||
|
Helpers refer to each other; only tests call `cloneSourceInfo`. Active root-owned
|
||||||
|
records come through [conversion.go](../../conversion.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Delete the disconnected family and tests solely
|
||||||
|
protecting it. Keep conversion assertions and useful returned-record ownership
|
||||||
|
coverage through repeated public queries and mutation of mutable nested results.
|
||||||
|
Replacing an entire `Destination` field on a copied struct does not demonstrate
|
||||||
|
nested detachment. Keep active engine clones and `Path.clone`; do not expose
|
||||||
|
opaque path internals or invent a stronger copy guarantee. Coordinate F12.
|
||||||
|
|
||||||
|
### F05. Remove Uncalled Engine Helpers
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 4; placement subset also complexity 5.
|
||||||
|
|
||||||
|
The type-aware audit found no callers or function-value uses in the inspected
|
||||||
|
production/test build configurations for the following helpers:
|
||||||
|
|
||||||
|
| Candidate | Active counterpart to preserve |
|
||||||
|
| --- | --- |
|
||||||
|
| `mergeDisjointMappings` in [compose.go](../../internal/engine/compose.go) | `mergeDisjointMappingsAtPath` and shared recursive merging; the old helper references only itself. |
|
||||||
|
| `mergeHistoryDocument` in [history.go](../../internal/engine/history.go) | `mergeHistoryDocumentAtPath` with sequence evidence. |
|
||||||
|
| `inheritanceDirectiveLocation` in [inheritance.go](../../internal/engine/inheritance.go) | Preparation's directive entry/location handling. |
|
||||||
|
| `addSecretEvidencePath` in [secret.go](../../internal/engine/secret.go) | Projected version sensitivity evidence and the active path accumulator. |
|
||||||
|
| `PlacementModeFromFacade`, `PlacementModeForFacade` in [destination.go](../../internal/engine/destination.go) | Input/options conversion and `SourceInfoPlacementModeForFacade`. |
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove these definitions and imports/comments
|
||||||
|
solely supporting them after confirming references at implementation time. Preserve
|
||||||
|
active counterparts and public root APIs. The placement pair can be removed
|
||||||
|
without selecting the broader transport work in F07.
|
||||||
|
|
||||||
|
### F06. Trim Unread Private Result And Transport Members
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 6.
|
||||||
|
|
||||||
|
The audit identified the following populated members without production readers.
|
||||||
|
Some private tests inspect them. Remove only these slots and computations solely
|
||||||
|
feeding them, preserving validation and active outputs:
|
||||||
|
|
||||||
|
| Owner and candidate members | Boundary to preserve |
|
||||||
|
| --- | --- |
|
||||||
|
| `collectionPolicyResolution.declarations`, `declarationsPresent`, `identityDeclarationPresent`, `applicationIdentityFields` in [collection_policy.go](../../internal/engine/collection_policy.go) | Local declaration normalization, `modeDeclarationPresent`, and `applicationIdentityPresent`. |
|
||||||
|
| `inputPresence.null` in [decode.go](../../internal/engine/decode.go) | Presence/location and effective-node null reporting. |
|
||||||
|
| `rootDirectiveExtraction.importsPresent`, `sequencesPresent` in [directives.go](../../internal/engine/directives.go) | Parsed declarations, diagnostics, and directive validation. |
|
||||||
|
| `importSelection.outcome` in [import_selection.go](../../internal/engine/import_selection.go) and glob selection | Candidate/admission behavior and the distinct, active public `importSelectionEvidence.outcome`. |
|
||||||
|
| `importDeclarationSpec.excludeSet` in [import_spec.go](../../internal/engine/import_spec.go) | Active `exclude` policy. |
|
||||||
|
| `schemaPath.field`, `dynamic` in [schema.go](../../internal/engine/schema.go) | Descriptor and success result used by path consumers; coordinate A01. |
|
||||||
|
| `selectedOverlayRequest.filesystem` in [selected_overlay.go](../../internal/engine/selected_overlay.go) | Explicit filesystem-access argument and borrowed ownership. |
|
||||||
|
| `sequenceDeclarationSpec.destinationLocation` in [sequence_declaration.go](../../internal/engine/sequence_declaration.go) | Other active declaration/field diagnostic locations. |
|
||||||
|
| `sourceVersionEvidence.absence`, `checked`; `sourceVersionAncestor.kind` in [source_version.go](../../internal/engine/source_version.go) | Actual checks, version/presence/location evidence, ancestor paths, and sensitivity. |
|
||||||
|
| `postDecodeOverrideContext.sources` in [override_context.go](../../internal/engine/override_context.go) | Local source list used to build callback resolution input. |
|
||||||
|
|
||||||
|
**Acceptance.** Preserve public presence, null, selection, version, diagnostic,
|
||||||
|
and ownership behavior. Rework assertions on inactive annotations only where
|
||||||
|
they protect a meaningful supported outcome. A lack of selector reads alone is
|
||||||
|
not a general removal rule: map keys, embedded records, and interfaces require
|
||||||
|
separate analysis.
|
||||||
|
|
||||||
|
### F07. Simplify Redundant Facade-To-Engine Transport
|
||||||
|
|
||||||
|
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
|
||||||
|
belong to [future work](future.md#2-simplify-redundant-facade-to-engine-transport),
|
||||||
|
for consideration after this roadmap's selected work is complete. F05's bounded
|
||||||
|
removal of unused placement bridges remains selected.
|
||||||
|
|
||||||
|
### F08. Index Secret-Failure Location Queries Lazily
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: low. Source: efficiency 5.
|
||||||
|
|
||||||
|
**Problem and evidence.** `secretDiagnostic` in
|
||||||
|
[secret_reference.go](../../internal/engine/secret_reference.go) calls unindexed
|
||||||
|
nearest-location traversal for each failed reference. N missing references in a
|
||||||
|
wide mapping incur O(N²) comparisons, even when they use one cached environment
|
||||||
|
name. Normal deterministic traversal/sorting does not require these scans.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider a lazy read-only `documentLookup`
|
||||||
|
shared during secret resolution, or carry relevant location state through the
|
||||||
|
walk. With V nodes, F failures, and depth D, attribution can be O(V + F × D),
|
||||||
|
apart from sorting. Keep the successful path free of an unnecessary eager index.
|
||||||
|
Preserve all diagnostics, ancestor fallback, source coordinates, and safe messages;
|
||||||
|
failing fast would change behavior rather than optimize it.
|
||||||
|
|
||||||
|
### F09. Cover Public Historical-Path Enumeration
|
||||||
|
|
||||||
|
Type: test improvement. Priority: medium. Source: testing 2; related testing 6.
|
||||||
|
|
||||||
|
**Problem and evidence.** Root `Metadata.HistoricalPaths` in
|
||||||
|
[metadata.go](../../metadata.go) had no executed statements in the aggregate
|
||||||
|
profile. Engine tests cover it thoroughly, but not root path conversion. An empty
|
||||||
|
or incorrectly converted public result could leave engine tests green.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Add one external-package test enumerating
|
||||||
|
effective and removed paths, using returned paths with `Lookup` and `History`,
|
||||||
|
including a numeric-key/sequence shape change. Cover sorted unique pointer
|
||||||
|
spelling, zero metadata, and replacement of a returned slice element without
|
||||||
|
affecting later queries. Keep detailed path mutation/shape tests in the engine.
|
||||||
|
This can replace F12's private segment-representation assertion without copying
|
||||||
|
the complete internal matrix or exposing private segments.
|
||||||
|
|
||||||
|
### F10. Strengthen Fuzz Detachment Assertions
|
||||||
|
|
||||||
|
Type: test improvement. Priority: low. Source: testing 4.
|
||||||
|
|
||||||
|
**Problem and evidence.** `FuzzCompositionAndOverlay` in
|
||||||
|
[composition_fuzz_test.go](../../internal/engine/composition_fuzz_test.go) appends
|
||||||
|
to the result root then compares inputs. A shallow root copy sharing existing
|
||||||
|
descendants can pass because append changes neither the input's slice header nor
|
||||||
|
elements within its old length. Existing deterministic merge tests already
|
||||||
|
exercise meaningful nested mutation.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Mutate an existing result descendant or
|
||||||
|
in-length element within the bounded fuzz target, then compare both inputs.
|
||||||
|
Handle empty containers separately and retain input immutability/commutativity
|
||||||
|
properties. Improve the existing assertion rather than adding another ownership
|
||||||
|
matrix or claiming all ownership coverage is absent.
|
||||||
|
|
||||||
|
### F11. Replace Exact Stat-Call Choreography With Observable Ordering
|
||||||
|
|
||||||
|
Type: test cleanup. Priority: low. Source: testing 5.
|
||||||
|
|
||||||
|
**Problem and evidence.** `TestSelectNamedImportsChecksNormalizedPathsLexically`
|
||||||
|
in [import_selection_test.go](../../internal/engine/import_selection_test.go)
|
||||||
|
requires `a.yaml,a.yaml,z.yaml,z.yaml` stat calls but checks candidate count only.
|
||||||
|
Lexical handling matters; exactly two stats per file is not an established contract.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Assert ordered candidate paths and, where
|
||||||
|
needed, the first diagnostic site using failing targets. Consolidate with existing
|
||||||
|
selection tests and delete the standalone case if its protection is subsumed.
|
||||||
|
Keep meaningful observations such as not opening excluded contents. Do not turn
|
||||||
|
incidental helper call counts into an efficiency or behavior guarantee.
|
||||||
|
|
||||||
|
### F12. Consolidate Facade Scaffolding Around Public Behavior
|
||||||
|
|
||||||
|
Type: test cleanup. Priority: low. Source: testing 6; related deadcode 3.
|
||||||
|
|
||||||
|
**Evidence and recommendations.**
|
||||||
|
|
||||||
|
- Remove the duplicate six-type ownership loop in
|
||||||
|
[facade_compatibility_test.go](../../facade_compatibility_test.go), retaining the
|
||||||
|
comprehensive owner in [facade_candidate_test.go](../../facade_candidate_test.go),
|
||||||
|
compile-time interfaces, public construction, and distinct lifecycle checks.
|
||||||
|
A literal assignment equality assertion on a new `Result.Config` adds no
|
||||||
|
protection beyond compilation and ordinary Go assignment.
|
||||||
|
- Replace the former `TestWrappedPathsRetainEngineSegmentIntent` internal
|
||||||
|
representation check with public contextual mapping/index behavior under F09.
|
||||||
|
Keep engine segment-intent coverage; public source-form behavior is consolidated
|
||||||
|
in [facade_equivalence_test.go](../../facade_equivalence_test.go).
|
||||||
|
- Move `TestWrappedSourcesPreserveEngineOwnership` from direct `engine.Load`
|
||||||
|
through `source.toEngine()` to public `Load` and root error assertions.
|
||||||
|
Consolidate reader cases with [facade_equivalence_test.go](../../facade_equivalence_test.go)
|
||||||
|
while preserving explicit copied-reader and byte-source mutation coverage.
|
||||||
|
- F04 owns removal of the disconnected clone family and the ineffective whole-
|
||||||
|
destination assignment assertion in [records_test.go](../../records_test.go).
|
||||||
|
Keep useful conversion and returned-record ownership protection there or in
|
||||||
|
existing public source-snapshot tests.
|
||||||
|
|
||||||
|
**Acceptance.** Remove scaffolding only after preserving its distinct behavioral
|
||||||
|
risks. Root integration tests still protect conversion, error typing, callbacks,
|
||||||
|
source identity, disclosure, and ownership; engine coverage does not substitute
|
||||||
|
for them. Static public/dependency-boundary tests remain useful.
|
||||||
|
|
||||||
|
## Boundaries To Preserve
|
||||||
|
|
||||||
|
The audits found the architecture coherent and did not establish a need for new
|
||||||
|
public features, a replacement loading API, package proliferation, or a general
|
||||||
|
visitor/strategy framework. Root-owned conversions, synchronous load-local state,
|
||||||
|
shared dependency scheduling, indexed combination, separated inheritance phases,
|
||||||
|
and passive resource contexts remain useful owners. The clone traversal was
|
||||||
|
already reviewed and retained at `34cbc0d:docs/roadmap/consolidation.md`; a high
|
||||||
|
complexity estimate does not reopen it.
|
||||||
|
|
||||||
|
No additional supported-use data race, deadlock, background-work leak, credential
|
||||||
|
disclosure, command injection, authority bypass, or host containment escape was
|
||||||
|
established. These are limited audit observations, not absence guarantees.
|
||||||
|
Caller-owned mutable objects, blocking operations, safe callback messages, and
|
||||||
|
borrowed filesystem behavior retain their documented responsibilities. No new
|
||||||
|
locking, cancellation, retry, authentication, permission, or environment-variable
|
||||||
|
restriction follows from these findings.
|
||||||
|
|
||||||
|
Preserve raw-error suppression, structured safe diagnostics, original application
|
||||||
|
panic propagation, intentional bootstrap partial writes, allowed partial writer
|
||||||
|
output, and documented read-only regular-file close handling. Regression tests
|
||||||
|
must enforce existing contracts rather than broaden them. Optional absence must
|
||||||
|
remain distinct from acquisition failure, and genuine finite reuse from cycles.
|
||||||
|
|
||||||
|
Public APIs are not dead merely because repository examples omit them. Interface
|
||||||
|
dispatch, reflection, build constraints, identity tokens, map-key equality, and
|
||||||
|
uninstantiated generics require more than caller counts. In particular,
|
||||||
|
`decodeInlineMapEntry` and import-cycle diagnostics remain active.
|
||||||
|
`Inspection.resolution` retains recorded snapshots under the existing resolution
|
||||||
|
contract even though replay uses a coalesced view; it is not a cleanup candidate.
|
||||||
|
|
||||||
|
Selected stable Kahn ordering, deterministic sorting, exact numeric identity,
|
||||||
|
and the expressly selected registration-overlap check are not reconsidered here.
|
||||||
|
Required detached copies and genuinely expanding output remain legitimate costs.
|
||||||
|
The broader replay, metadata-handoff, cross-load schema-cache, and contributor-
|
||||||
|
trace experiments stay in [future.md](future.md#5-performance-experiments).
|
||||||
|
Local fixes and removal of proven unused work do not select those experiments.
|
||||||
|
|
||||||
|
## Validation Approach For Selected Work
|
||||||
|
|
||||||
|
Use the existing [development guide](../development.md#baseline-validation) and
|
||||||
|
[testing policy](../policy/testing.md). Add meaningful regressions with the
|
||||||
|
selected fixes, at the narrowest stable boundary that detects the reported
|
||||||
|
failure. Extend existing owners rather than duplicating internal matrices at
|
||||||
|
every layer. Assert nil results and safe structured failures where relevant,
|
||||||
|
and verify retained state/handles independently when they have distinct owners.
|
||||||
|
|
||||||
|
Keep termination, allocation, and link-expansion probes bounded. Use small
|
||||||
|
controlled budgets, disposable processes where necessary, and filesystem
|
||||||
|
observation where it detects the specific defect. No wall-clock thresholds,
|
||||||
|
coverage percentages, complexity-score limits, exact incidental syscall traces,
|
||||||
|
new resource caps, or extra release gates are selected. Algorithmic evidence is
|
||||||
|
not a measured speedup. Performance experiments need separate scope and suitable
|
||||||
|
measurement conditions; no benchmarks were run for this consolidation.
|
||||||
|
|
||||||
|
Once selected work changes behavior or structure, update its canonical contract
|
||||||
|
or internal owner as appropriate. This review document remains the finding/status
|
||||||
|
owner until an implementation plan is prepared; completed records can retire
|
||||||
|
to Git history under the normal documentation lifecycle.
|
||||||
|
|
||||||
|
## Original-Finding Map
|
||||||
|
|
||||||
|
Original files are retrieved from `ecd5d5d:docs/roadmap/<filename>`. Every numbered
|
||||||
|
finding is mapped below; repeated citations are corroboration or shared coverage,
|
||||||
|
not extra fixes.
|
||||||
|
|
||||||
|
| Original finding | Consolidated owner |
|
||||||
|
| --- | --- |
|
||||||
|
| correctness 1 | [A01](#a01-terminate-pointer-only-recursive-descriptor-traversal) |
|
||||||
|
| correctness 2 | [A02](#a02-make-failed-dynamic-descriptor-construction-transactional) |
|
||||||
|
| correctness 3 | [A03](#a03-distinguish-acyclic-slice-views-in-cycle-detection) |
|
||||||
|
| correctness 4 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding) |
|
||||||
|
| efficiency 1 | [E01](#e01-avoid-scanning-every-policy-for-each-replacement) |
|
||||||
|
| efficiency 2 | [E02](#e02-avoid-recopying-unrelated-policy-across-small-tiers) |
|
||||||
|
| efficiency 3 | [E04](#e04-index-tier-history-projection-lookups) |
|
||||||
|
| efficiency 4 | [E05](#e05-restore-compound-identity-without-repeated-mapping-scans) |
|
||||||
|
| efficiency 5 | [F08](#f08-index-secret-failure-location-queries-lazily) |
|
||||||
|
| efficiency 6 | [A05](#a05-reuse-successful-dynamic-type-eligibility-checks-locally) |
|
||||||
|
| complexity 1 | [F01](#f01-remove-the-test-only-eager-acquisition-mode) |
|
||||||
|
| complexity 2 | [F02](#f02-clarify-active-acquisition-and-coordinator-handoffs) |
|
||||||
|
| complexity 3 | [E03](#e03-clarify-policy-preparation-and-merge-evidence-application) |
|
||||||
|
| complexity 4 | [C03](#c03-separate-glob-entry-preparation-from-traversal) |
|
||||||
|
| complexity 5 | [F07](#f07-simplify-redundant-facade-to-engine-transport), with bounded bridge removal in [F05](#f05-remove-uncalled-engine-helpers) |
|
||||||
|
| concurrency 1 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding), corroboration |
|
||||||
|
| resources 1 | [A04](#a04-enforce-existing-node-budgets-before-container-reservations) |
|
||||||
|
| resources 2 | [D01](#d01-account-for-the-complete-published-history-graph) |
|
||||||
|
| resources 3 | [B02](#b02-close-the-owned-import-root-when-a-version-callback-panics) |
|
||||||
|
| error-handling 1 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests) |
|
||||||
|
| security 1 | [C02](#c02-avoid-reexpanding-completed-finite-symlink-dependencies) |
|
||||||
|
| testing 1 | Regression acceptance under A01–A04, B01–B02, C02, and D01 |
|
||||||
|
| testing 2 | [F09](#f09-cover-public-historical-path-enumeration) |
|
||||||
|
| testing 3 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests), including shared fixture assertions |
|
||||||
|
| testing 4 | [F10](#f10-strengthen-fuzz-detachment-assertions) |
|
||||||
|
| testing 5 | [F11](#f11-replace-exact-stat-call-choreography-with-observable-ordering) |
|
||||||
|
| testing 6 | [F12](#f12-consolidate-facade-scaffolding-around-public-behavior), with F04 and F09 |
|
||||||
|
| deadcode 1 | [D02](#d02-remove-the-unconsumed-override-result-pre-cli-copy) |
|
||||||
|
| deadcode 2 | [D03](#d03-remove-the-unqueried-published-structural-input-copy) |
|
||||||
|
| deadcode 3 | [F04](#f04-remove-the-disconnected-root-record-clone-family) |
|
||||||
|
| deadcode 4 | [F05](#f05-remove-uncalled-engine-helpers) |
|
||||||
|
| deadcode 5 | [D04](#d04-stop-constructing-unconsumed-combination-evidence) |
|
||||||
|
| deadcode 6 | [F06](#f06-trim-unread-private-result-and-transport-members) |
|
||||||
|
| deadcode 7 | [F03](#f03-retire-the-parallel-test-only-import-directive-parser) |
|
||||||
|
| deadcode 8 | [F01](#f01-remove-the-test-only-eager-acquisition-mode), duplicate of complexity 1 |
|
||||||
|
|
||||||
|
## Settled Scope Decisions
|
||||||
|
|
||||||
|
### OQ-1. What Should The First Implementation Scope Include?
|
||||||
|
|
||||||
|
**Settled:** include all nine reported defects with their regression coverage,
|
||||||
|
the bounded cleanup, and the identified independent test improvements.
|
||||||
|
|
||||||
|
| Selected work | Finding IDs |
|
||||||
|
| --- | --- |
|
||||||
|
| Reported defects and accompanying regression coverage | A01–A04, B01–B02, C01–C02, D01 |
|
||||||
|
| Removal of unused copies, evidence, helpers, fields, and test-only production paths | D02–D04, F01, F03–F06 |
|
||||||
|
| Public coverage and stronger or less redundant test assertions | F09–F12 |
|
||||||
|
|
||||||
|
This selects 21 review entries. Reconfirm cleanup evidence on the implementation
|
||||||
|
baseline before removal, and preserve distinct behavioral protection when
|
||||||
|
replacing tests. Coordinate unused history copies with D01's retained-graph
|
||||||
|
accounting; verify callback-state closure and host-root closure independently.
|
||||||
|
Keep fixes and cleanup in focused, reviewable commits, even if delivered in one
|
||||||
|
sprint. Cleanup must not delay a consequential fix.
|
||||||
|
|
||||||
|
The combined scope restores existing contracts and removes demonstrated
|
||||||
|
maintenance burden while avoiding repeated work at the same ownership and test
|
||||||
|
boundaries. Delivering defects first and cleanup separately would reduce the
|
||||||
|
initial review surface, but would retain redundant work and revisit those
|
||||||
|
boundaries. The combined approach is selected.
|
||||||
|
|
||||||
|
Efficiency and broader structural choices are addressed separately below. This
|
||||||
|
decision does not select new public behavior or establish an implementation
|
||||||
|
sequence.
|
||||||
|
|
||||||
|
### OQ-2. Which Efficiency Candidates Should Accompany Remediation?
|
||||||
|
|
||||||
|
**Settled:** include the four localized efficiency improvements below. E01/E02
|
||||||
|
are addressed separately with collection-policy preparation and evidence
|
||||||
|
ownership (E03) under the settled OQ-3 decision.
|
||||||
|
|
||||||
|
| Selected improvement | Scope and dependency |
|
||||||
|
| --- | --- |
|
||||||
|
| E04: tier-history projection lookups | Reuse a local lookup index or equivalent traversal while preserving authored coordinates and projection evidence. |
|
||||||
|
| E05: compound identity restoration | Avoid repeated mapping scans while preserving child identity, mapping order, and detached nodes. |
|
||||||
|
| A05: dynamic-type eligibility reuse | Reuse only completed successful checks within the operation, after A02 repairs descriptor construction failure state. |
|
||||||
|
| F08: secret-failure location lookup | Include at lower priority; prepare lookup state lazily so successful loads avoid unnecessary indexing. |
|
||||||
|
|
||||||
|
Together with OQ-1, this selects 25 review entries. These local changes address
|
||||||
|
source-demonstrated repeated work without requiring public APIs, persistent
|
||||||
|
caches, or a new architectural model. Preserve their behavioral acceptance
|
||||||
|
cases and keep implementation proportionate. E01/E02 involve policy retirement
|
||||||
|
timing and mutable ownership, so their scope belongs with the E03 discussion.
|
||||||
|
|
||||||
|
Deferring all six until consumer profiling was considered. That would reduce
|
||||||
|
initial scope and avoid optimizing uncommon workloads, but would retain known
|
||||||
|
growth patterns with relatively direct remedies in the four selected cases.
|
||||||
|
The selected approach does not promise a measured startup improvement; practical
|
||||||
|
benefit depends on configuration shape and workload. Broader experiments in
|
||||||
|
[future.md](future.md#5-performance-experiments) remain deferred. C02's availability
|
||||||
|
fix and A04/D01's existing-budget enforcement remain selected independently.
|
||||||
|
|
||||||
|
### OQ-3. How Much Structural Refactoring Should Be Selected?
|
||||||
|
|
||||||
|
**Settled:** select E01, E02, E03, and F02. Defer C03 and F07 to the first two
|
||||||
|
entries of [future.md](future.md) for consideration after the rest of this roadmap
|
||||||
|
is complete. F01's removal of the test-only protocol remains selected under OQ-1.
|
||||||
|
|
||||||
|
| Selected work | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| E01/E02/E03: collection-policy retirement, copying, and evidence ownership | Address the related costs and preparation/application boundaries together, retaining distinct scaling and behavioral acceptance cases. |
|
||||||
|
| F02: active acquisition and coordinator handoffs | Extract cohesive repeated operations with clear inputs/results while preserving visible phase order and ordinary/layered differences. |
|
||||||
|
|
||||||
|
For collection policy, distinguish read-only existing state, policy affected by
|
||||||
|
incoming declarations or replacements, and the changes/evidence retained by the
|
||||||
|
assembly. Preserve retirement timing, newly established policy, explicit-mode
|
||||||
|
persistence, and nested record ownership. A smaller function or fewer copies
|
||||||
|
alone does not establish success. For acquisition, moving blocks into helpers
|
||||||
|
that continue sharing mutable surrounding state would not clarify the handoff;
|
||||||
|
keep the existing loading model and named responsibilities.
|
||||||
|
|
||||||
|
The selected combination offers a focused improvement in efficiency and
|
||||||
|
maintainability at active ownership boundaries. Including C03/F07 now would
|
||||||
|
broaden review for more modest readability benefits; neither is necessary for
|
||||||
|
the selected fixes. They remain plausible follow-up work, not rejected proposals.
|
||||||
|
Deferring all structural work would reduce immediate change scope but retain the
|
||||||
|
policy inefficiencies and difficult acquisition handoffs. No new public API,
|
||||||
|
generic framework, or configuration semantics are selected.
|
||||||
|
|
||||||
|
Together, OQ-1 through OQ-3 select 29 entries. All scope questions are settled;
|
||||||
|
the next step is to prepare the implementation plan with sequencing and focused
|
||||||
|
validation. No implementation has been performed by these planning decisions.
|
||||||
@@ -1,112 +1,34 @@
|
|||||||
# Potential Future Features
|
# Potential Future Work
|
||||||
|
|
||||||
This document lists potential enhancements for consideration in future ConfigKit
|
This document lists potential enhancements and maintenance work for consideration
|
||||||
releases. Entries are not accepted contracts, implementation plans, or release
|
in future ConfigKit releases. Entries are not accepted contracts, implementation
|
||||||
commitments. Adopting a candidate requires an explicit design decision and a
|
plans, or release commitments. Adopting a candidate requires an explicit design
|
||||||
separate plan. Significant architectural exclusions and their rationale belong
|
decision and a separate plan. Significant architectural exclusions and their rationale belong
|
||||||
in ADRs; this document does not maintain a list of rejected proposals.
|
in ADRs; this document does not maintain a list of rejected proposals.
|
||||||
|
|
||||||
## 1. Identified record sequences
|
## 1. Separate glob entry preparation from traversal
|
||||||
|
|
||||||
Status: deferred. PromptKit proposed this capability from its retired proposal 2,
|
Implemented in the [internal-boundary roadmap](internal-boundaries.md#glob-entry-preparation) (C03).
|
||||||
**Namespaced Collections As Identified Records**. The original proposal is in the
|
|
||||||
PromptKit repository at
|
|
||||||
`b5866dd:docs/roadmap/configuration-collections-identified-records.md`.
|
|
||||||
PromptKit selected mounted mappings instead; this feature is not a dependency
|
|
||||||
of that design or a request to restore multiple installed prompt versions.
|
|
||||||
|
|
||||||
A narrower [sequence placement and composition feature](../config.md#strict-document-composition)
|
## 2. Simplify redundant facade-to-engine transport
|
||||||
is implemented: consumers can place imported mappings as sequence items and
|
|
||||||
register a single literal string identity field for duplicate-rejecting strict
|
|
||||||
composition. Precedence still replaces sequences as whole values. This entry
|
|
||||||
tracks broader identified-record behavior that remains deferred and is not part
|
|
||||||
of that implemented contract.
|
|
||||||
|
|
||||||
### Motivation and proposed behavior
|
Implemented in the [internal-boundary roadmap](internal-boundaries.md#facade-to-engine-transport) (F07).
|
||||||
|
|
||||||
Allow applications to declare that selected sequences contain records identified
|
## 3. Recursive body merging for identified records
|
||||||
by explicit fields, while retaining sequence-shaped output for slice decoding
|
|
||||||
and effective YAML. This could make composite identities and independently
|
|
||||||
authored catalogs more convenient than encoding identity in nested mapping keys.
|
|
||||||
Current [composition](../config.md#strict-document-composition) appends distinct
|
|
||||||
records only at explicitly registered paths; other sequences remain atomic.
|
|
||||||
Selected [overlays](../config.md#selected-overlays) always treat sequences
|
|
||||||
atomically.
|
|
||||||
|
|
||||||
The proposed broader opt-in registration would extend the current reusable
|
Status: deferred. The implemented
|
||||||
application definition from one identity field to a composite identity made of
|
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
||||||
an ordered, nonempty set of distinct direct string-field names. For example,
|
replace a complete matched record. Recursive merging of fields inside matching
|
||||||
records at `profiles` could be identified by `(namespace, id)`. Records
|
records remains a separate candidate and would revisit the whole-record update
|
||||||
`(provider, brief)`
|
boundary selected by [ADR 0017](../adr/0017-collection-sequence-policy.md).
|
||||||
and `(application, brief)` would coexist, and a later layer could update the
|
|
||||||
provider record's temperature while retaining its backend and model.
|
|
||||||
|
|
||||||
The requested semantics, all still subject to design review, are:
|
Consider this only in response to a concrete consumer need. A proposal would need
|
||||||
|
to define conflicts for overlapping body fields, removal of omitted fields,
|
||||||
|
presence and ordering, and how provenance, history, sensitivity, and resource
|
||||||
|
context follow partially retained records. Any change requires a new decision and
|
||||||
|
implementation plan; this entry does not alter current upsert behavior.
|
||||||
|
|
||||||
- Match exact string tuples, preserving component boundaries and spelling,
|
## 4. Required version declarations for selected sources
|
||||||
including empty strings. Every contribution supplies its complete identity;
|
|
||||||
defaults, inheritance, filenames, and normalization do not supply it.
|
|
||||||
- Retain distinct identities. Matching records from strict peers recursively
|
|
||||||
compose disjoint body fields and conflict on overlapping non-mapping body
|
|
||||||
values, even equal values. Equal identity fields act as structural keys rather
|
|
||||||
than conflicts and retain their contributing source evidence.
|
|
||||||
- Higher layers recursively overlay matching bodies. A changed identity denotes
|
|
||||||
a different record, not a rename. Proposed ordering is first contribution in
|
|
||||||
precedence-assembly order; matching records keep position and new ones append.
|
|
||||||
- Reject malformed identities, non-mapping records, non-null non-sequence values
|
|
||||||
at registered paths, and duplicate tuples within one source sequence. Later
|
|
||||||
layers cannot repair malformed structural identity in an acquired contribution.
|
|
||||||
- Treat an empty registered sequence as contributing no records; whole-path null
|
|
||||||
retains ordinary conflict/replacement semantics. Unregistered sequences,
|
|
||||||
including nested message lists, retain existing atomic behavior.
|
|
||||||
- Support record inheritance within the same registered sequence using an
|
|
||||||
`extends` mapping containing the complete parent tuple. Resolve only final
|
|
||||||
active relationships after precedence, preserve the child's identity, overlay
|
|
||||||
its body on the parent's body, and consume the directive before decoding.
|
|
||||||
Preserve cancellation, missing-parent/cycle errors, and declaring resource
|
|
||||||
contexts. Existing string-based mapping-sibling inheritance stays unchanged.
|
|
||||||
|
|
||||||
Integrate ordinary and layered loads, imports, mounted paths, selected overlays,
|
|
||||||
and whole-sequence CLI overrides. Keep origins, presence, history, sensitivity,
|
|
||||||
and resource contexts attached to the correct record despite different source
|
|
||||||
and final indexes. Diagnostics must retain source locations without exposing
|
|
||||||
sensitive identity values. Final inspection may use snapshot-specific sequence
|
|
||||||
indexes; existing atomic sequence diffs need not become identity-aware.
|
|
||||||
|
|
||||||
Successful results would have valid, unique identities, including after defaults
|
|
||||||
and application resolution, without a second merge or inheritance pass. Existing
|
|
||||||
source-version checks remain independent; consumed headers cannot supply missing
|
|
||||||
record identities. No identity-selector path syntax, arbitrary merge callbacks,
|
|
||||||
wildcard registration, per-record deletion/rename operator, multiple inheritance,
|
|
||||||
or cross-path parent references are requested.
|
|
||||||
|
|
||||||
### Questions to resolve before implementation
|
|
||||||
|
|
||||||
- **Need and delivery scope:** does an active consumer benefit enough to justify
|
|
||||||
the added semantics over keyed mappings? Are composite identities required?
|
|
||||||
Staging composition before inheritance is possible but would leave part of
|
|
||||||
the request incomplete.
|
|
||||||
- **Defaults and callbacks:** do default records merge by identity or retain
|
|
||||||
existing sequence-replacement behavior? How are callback-created duplicates,
|
|
||||||
identity edits, and reordering validated and reflected in provenance?
|
|
||||||
- **Null, empty, and order:** what happens when null clears a collection and a
|
|
||||||
later layer reintroduces records? How do absent and explicitly empty sequences
|
|
||||||
interact with defaults and first-contribution order?
|
|
||||||
- **Inheritance references:** how do peers compose relationship declarations,
|
|
||||||
and how are complete references enforced without assembling partial tuples
|
|
||||||
accidentally? What happens if identity fields overlap structural controls?
|
|
||||||
- **Registration paths:** how do overlapping registrations and paths beneath
|
|
||||||
sequence indexes behave when assembly changes those indexes?
|
|
||||||
- **Evidence and lifecycle:** how are record identities mapped between source
|
|
||||||
trees, history, inheritance, callback snapshots, and final indexes without
|
|
||||||
mixing records' provenance or sensitivity? Where do identity checks run relative
|
|
||||||
to header consumption and other structural processing?
|
|
||||||
|
|
||||||
Keep the feature deferred until its consumer value and full lifecycle design
|
|
||||||
justify reconsidering the sequence-merging exclusion. A correct final sequence
|
|
||||||
alone would not establish correct inspection or disclosure behavior.
|
|
||||||
|
|
||||||
## 2. Required version declarations for selected sources
|
|
||||||
|
|
||||||
Status: deferred. PromptKit requested a way to require version declarations on
|
Status: deferred. PromptKit requested a way to require version declarations on
|
||||||
a selected subset of source files while permitting other fragments to omit them.
|
a selected subset of source files while permitting other fragments to omit them.
|
||||||
@@ -142,7 +64,7 @@ compatible version or replace final configuration validation.
|
|||||||
behavior for absent optional inputs and preserve source-local diagnostics,
|
behavior for absent optional inputs and preserve source-local diagnostics,
|
||||||
inspection evidence, and existing sensitivity handling.
|
inspection evidence, and existing sensitivity handling.
|
||||||
|
|
||||||
## 3. Performance experiments
|
## 5. Performance experiments
|
||||||
|
|
||||||
Status: deferred pending evidence. The completed performance audit identified
|
Status: deferred pending evidence. The completed performance audit identified
|
||||||
four internal optimization candidates for separate investigation:
|
four internal optimization candidates for separate investigation:
|
||||||
|
|||||||
296
docs/roadmap/implementation.md
Normal file
296
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# Import Discovery And Facade Simplification Implementation Plan
|
||||||
|
|
||||||
|
Status: complete.
|
||||||
|
Baseline: `bb5540b62e382106d59678e2edb7655acfd5c960`.
|
||||||
|
Audience: a Luna coding agent implementing the stages sequentially.
|
||||||
|
|
||||||
|
## Scope And Execution
|
||||||
|
|
||||||
|
Implement **C03** and **F07** from the
|
||||||
|
[internal-boundary roadmap](internal-boundaries.md). That document owns the target
|
||||||
|
state and preservation requirements; this plan owns concrete private choices,
|
||||||
|
steps, validation, and progress. The preceding 29-finding remediation sprint is
|
||||||
|
complete. Its implementation record remains at
|
||||||
|
`bb5540b:docs/roadmap/implementation.md`; this plan does not reopen it.
|
||||||
|
|
||||||
|
Read `AGENTS.md`, [agent policy](../policy/agents.md), the [development
|
||||||
|
guide](../development.md), and applicable architecture, testing, and documentation
|
||||||
|
policies. Use graph-first discovery and verify actual callers before deleting
|
||||||
|
helpers. Preserve unrelated work.
|
||||||
|
|
||||||
|
This is one end-to-end sprint. Intermediate stages may fail to compile, especially
|
||||||
|
the coordinated engine/facade cutover in Stages 2–3. Do not create adapters,
|
||||||
|
aliases, duplicate paths, or extra abstractions solely to make intermediate
|
||||||
|
stages green. Run focused checks once their affected surface is buildable; record
|
||||||
|
pending checks and finish them before final acceptance. Commit focused nonempty
|
||||||
|
units under repository policy and record stage status, actual validation, and
|
||||||
|
known commit identifiers here. Completion requires the integrated result, not
|
||||||
|
just checked-off stages.
|
||||||
|
|
||||||
|
### Boundaries
|
||||||
|
|
||||||
|
- Preserve public root-owned declarations and signatures, YAML syntax, loading
|
||||||
|
semantics, ordinary/layered differences, callback order, diagnostics, source
|
||||||
|
identity, inspection, confidentiality, and ownership.
|
||||||
|
- Preserve glob literal-versus-discovered rules and directory/glob differences.
|
||||||
|
Keep completed-link reuse, directory failure phases, containment, and existing
|
||||||
|
accounting. This is not a discovery algorithm or permission-policy change.
|
||||||
|
- Keep `resolvedImportPolicy`, registration compilation, normalized request
|
||||||
|
responsibilities, and conversions with real ownership or domain meaning.
|
||||||
|
Simplification is limited to defaults transport and placement construction.
|
||||||
|
- Add no dependencies, caller controls, caches, limits, background work, release
|
||||||
|
gates, or performance promises. Do not publish a release, update consumers, or
|
||||||
|
revise accepted ADR decisions.
|
||||||
|
|
||||||
|
### Selected Ownership Model
|
||||||
|
|
||||||
|
Use the existing engine `ImportDefaults` name and its exported `Required`,
|
||||||
|
`Follow`, `Hidden`, `Recursive`, and `Pattern` fields as the sole engine
|
||||||
|
input-defaults type. Root `ImportDefaults` stays separately defined. Root
|
||||||
|
conversion maps the fields without cloning them; the public loading wrappers
|
||||||
|
immediately enter engine preflight. `loadSettings` takes the per-load snapshot
|
||||||
|
before acquisition or callbacks, as it already does. The normalized request and
|
||||||
|
acquisition policy then borrow that load-owned read-only value.
|
||||||
|
|
||||||
|
Declaration resolution reads the owned defaults and retains its existing copies
|
||||||
|
of selected pattern/exclusion slices and destinations in `resolvedImportPolicy`.
|
||||||
|
This separates presence-aware load input from independently owned effective
|
||||||
|
declaration policy. No public copy-count or thread-safety guarantee follows.
|
||||||
|
|
||||||
|
For placement, expose the engine's existing enum as `PlacementMode uint8` with
|
||||||
|
`PlacementModeMapping` and `PlacementModeSequenceItem`, and expose placement
|
||||||
|
fields on engine input/options records for direct construction. These remain
|
||||||
|
internal-package implementation surfaces. Root types stay root-owned; direct
|
||||||
|
casts preserve unknown values for existing preflight validation.
|
||||||
|
|
||||||
|
### Validation Conventions
|
||||||
|
|
||||||
|
Run from the repository root with an installed compatible Go toolchain and
|
||||||
|
dependencies already available. Use `GOTOOLCHAIN=local`, `GOPROXY=off`, and
|
||||||
|
`GOTELEMETRY=off`. Keep disposable artifacts outside the repository and do not
|
||||||
|
change dependencies for tooling. Focused expressions identify behavior owners;
|
||||||
|
include a newly named regression explicitly if needed. Reuse unchanged successful
|
||||||
|
checks. No timing, allocation, exact syscall, or helper-shape assertions are
|
||||||
|
acceptance criteria.
|
||||||
|
|
||||||
|
## Stage 1 — Extract Patterned Glob Entry Preparation
|
||||||
|
|
||||||
|
Finding: **C03**. Owners: `internal/engine/glob.go`, existing glob selection,
|
||||||
|
link, and directory failure tests. Status: **complete**. Validation passed:
|
||||||
|
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'` and `git diff --check`.
|
||||||
|
|
||||||
|
1. Add `prepareGlobEntry` beside the selector. Take the filesystem access,
|
||||||
|
base path, parent `selectedImportCandidate`, `fs.DirEntry`, pattern segment,
|
||||||
|
`last` flag, and `resolvedImportPolicy`. Return a candidate, a boolean meaning
|
||||||
|
selected versus ordinary skip, and a nullable `*Diagnostic`. Use the parent's
|
||||||
|
existing `site`, which is propagated from the selector's root candidate.
|
||||||
|
No candidate-set, accounting, or traversal-state mutation belongs here.
|
||||||
|
2. Move only the patterned branch's per-entry body into this helper. Preserve
|
||||||
|
its order: `path.Match`; match/hidden filtering; lexical/access path creation
|
||||||
|
and containment; `importEntryTarget` and existing diagnostic translation;
|
||||||
|
follow filtering; intermediate-directory eligibility; host/native physical
|
||||||
|
path selection and canonicalization fallback; candidate label/site creation.
|
||||||
|
Return existing safe codes, paths, and sites. Do not turn inspection errors
|
||||||
|
into skips or skip validation merely because exclusion will later match.
|
||||||
|
3. Replace that inline body with the helper call and candidate insertion.
|
||||||
|
Leave pattern validation, `explicitGlobCandidate`, directory enumeration,
|
||||||
|
per-segment union/sorting, final-file validation, exclusions, requiredness,
|
||||||
|
and source-admission timing in the selector.
|
||||||
|
4. Keep directory entry preparation separate. Reuse existing filesystem/link
|
||||||
|
primitives; do not introduce a generic entry strategy or unify differing
|
||||||
|
missing-target, literal-link, filtering, or recursion rules. Remove imports
|
||||||
|
only when genuinely unused after extraction.
|
||||||
|
|
||||||
|
Acceptance: the selector now expresses traversal without embedding preparation
|
||||||
|
details. Reuse `glob_selection_test.go`, `glob_test.go`, filesystem-link tests,
|
||||||
|
and the preceding sprint's directory-phase regressions. They must still cover
|
||||||
|
literal hidden/link components versus wildcard filtering, aliases and native
|
||||||
|
spelling, escaping/cyclic links, matched-directory errors before exclusion,
|
||||||
|
selection order, and bounded accounting. Add a behavioral case only for a gap
|
||||||
|
identified during extraction, not a unit test asserting the new helper exists.
|
||||||
|
|
||||||
|
Focused check:
|
||||||
|
`go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'`.
|
||||||
|
|
||||||
|
## Stage 2 — Consolidate The Engine Defaults Representation
|
||||||
|
|
||||||
|
Finding: **F07**, engine representation. Owners: `internal/engine/import_spec.go`,
|
||||||
|
`load.go`, `layered.go`, `load_request.go`, `acquisition.go`, `options.go`, and
|
||||||
|
their callers/fixtures. Status: **complete**. Validation passed:
|
||||||
|
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and `git diff --check`. Root facade callers remain pending Stage 3.
|
||||||
|
|
||||||
|
1. Retain engine `ImportDefaults` with its existing five exported fields.
|
||||||
|
Remove private `importDefaults`, without a compatibility alias. Update
|
||||||
|
engine option/request/acquisition fields, helper signatures, readers, and
|
||||||
|
fixture literals to use the canonical type and exported default-field names.
|
||||||
|
Private containing fields such as `loadRequest.importDefaults` can keep their
|
||||||
|
names. Do not rename the distinct fields of `importDeclarationSpec` or
|
||||||
|
`resolvedImportPolicy` just because they have similar meanings.
|
||||||
|
2. Expose `ImportDefaults ImportDefaults` on engine `Options[T]` and
|
||||||
|
`LayeredOptions[T]`. Remove `OptionsWithImportDefaults`,
|
||||||
|
`LayeredOptionsWithImportDefaults`, and `privateImportDefaults`. Stage 3 changes
|
||||||
|
root callers; a temporary compile failure is preferable to an interim bridge.
|
||||||
|
3. Adapt `snapshotImportDefaults` to the canonical type, retaining nil boolean
|
||||||
|
pointers and nil versus non-nil empty pattern. Keep `cloneBool` and
|
||||||
|
`cloneStrings` where they still have real consumers. Keep current snapshot
|
||||||
|
call sites until Stage 3 establishes their final ownership placement.
|
||||||
|
4. Keep effective declaration policy resolution and validation semantics intact:
|
||||||
|
explicit false beats defaults, each control resolves independently, and
|
||||||
|
selector-specific applicability and depth checks stay unchanged. Do not
|
||||||
|
combine defaults with resolved policy or registration compilation.
|
||||||
|
|
||||||
|
Acceptance: one engine input-defaults type reaches every production path and
|
||||||
|
fixture; no equivalent private record or alias remains. Inspect ordinary,
|
||||||
|
layered, transitive-import, directory/glob, and test-helper call sites. The root
|
||||||
|
cutover and removal of redundant snapshots are explicitly pending until Stage 3.
|
||||||
|
|
||||||
|
Run the import/default focused tests after Stage 3 if the root cutover prevents
|
||||||
|
building the package here; do not report that deferred check as passed.
|
||||||
|
|
||||||
|
## Stage 3 — Simplify Facade Defaults Construction And Snapshot Ownership
|
||||||
|
|
||||||
|
Finding: **F07**, defaults handoff. Owners: root `conversion.go`, `lifecycle.go`,
|
||||||
|
`layered.go`; engine defaults/preflight/coordinator owners; `facade_import_defaults_test.go`
|
||||||
|
and `internal/engine/import_spec_test.go`. Status: **complete**. Validation passed:
|
||||||
|
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and `git diff --check`.
|
||||||
|
|
||||||
|
1. Set engine `ImportDefaults` fields directly in root `toEngineOptions` and
|
||||||
|
`toEngineLayeredOptions`. Retain `toEngineImportDefaults` as the explicit
|
||||||
|
root-to-engine type mapping, but map its booleans/pattern without copies.
|
||||||
|
Remove root `cloneImportDefaultBool` and `cloneImportDefaultStrings` after
|
||||||
|
confirming no other caller. Keep public root types and declaration contracts.
|
||||||
|
2. Keep `snapshotImportDefaults` in `loadSettings` as the load-entry ownership
|
||||||
|
operation for both APIs. Remove repeated snapshots in engine `LoadLayered`
|
||||||
|
request construction, `executeLoadRequest` acquisition-policy construction,
|
||||||
|
and `resolveImportPolicy`. Carry the owned defaults read-only thereafter.
|
||||||
|
Review all callers of those helpers, including test setup; direct declaration
|
||||||
|
resolution can read its input because it returns its own concrete policy.
|
||||||
|
3. Keep per-policy copies of the selected pattern, exclusions, and destination.
|
||||||
|
Do not borrow caller-owned storage beyond preflight, mutate the shared owned
|
||||||
|
defaults, or remove copying from unrelated option/record paths. Each new load
|
||||||
|
snapshots its own current inputs; add no reuse cache or ownership flag.
|
||||||
|
4. Replace the private conversion-snapshot fragment in
|
||||||
|
`TestImportDefaultsPreserveEmptyPatternAndRequiredness`, which converts options,
|
||||||
|
mutates inputs, and calls engine `Load`. It should no longer demand a snapshot
|
||||||
|
from a private conversion helper. Keep that test's public nil/empty,
|
||||||
|
requiredness, and inspection-evidence checks.
|
||||||
|
5. Protect the public snapshot contract with a compact ordinary/layered table:
|
||||||
|
use a main document with a version and imports, enable
|
||||||
|
`SourceVersionModeIfPresent`, and use a configuration type with version and
|
||||||
|
imported-value fields. In the main version-check callback, mutate the caller's
|
||||||
|
recursive default from true to false and its existing pattern element from
|
||||||
|
`*.cfg` to `*.skip`. This callback runs after preflight but before imported
|
||||||
|
discovery. Verify the original recursive/pattern selection still supplies a
|
||||||
|
value from a nested imported file. Use existing fixture helpers and no
|
||||||
|
concurrent mutation. A subsequent load with those changed defaults must omit
|
||||||
|
that imported contribution rather than reuse the prior snapshot. Keep fixtures
|
||||||
|
reusable or create fresh readers for each load.
|
||||||
|
6. Reuse or extend the small snapshot/policy tests for all four optional booleans,
|
||||||
|
explicit false, pattern mutation, nil, and non-nil empty pattern. Preserve
|
||||||
|
existing invalid-pattern-before-read/hook tests and declaration override,
|
||||||
|
transitive, underlay, and selected-overlay coverage. Do not assert exact copy
|
||||||
|
counts, pointer identities, or the private conversion boundary as public policy.
|
||||||
|
|
||||||
|
Acceptance: the engine/facade cutover builds; public defaults remain detached
|
||||||
|
before acquisition/callbacks, reused loads remain independent, and only genuine
|
||||||
|
ownership copies remain. Confirm the absence of full defaults snapshots at the
|
||||||
|
three removed downstream sites by source review, not production instrumentation.
|
||||||
|
|
||||||
|
Focused checks:
|
||||||
|
`go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and
|
||||||
|
`go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`.
|
||||||
|
|
||||||
|
## Stage 4 — Construct Placement Directly Across The Facade
|
||||||
|
|
||||||
|
Finding: **F07**, placement handoff. Owners: engine `destination.go`, `load.go`,
|
||||||
|
`layered.go`, normalized input and placement consumers; root `lifecycle.go`,
|
||||||
|
`layered.go`, and placement fixtures. Status: **complete**. Validation passed:
|
||||||
|
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, and `git diff --check`.
|
||||||
|
|
||||||
|
1. Rename the existing engine enum and its constants to `PlacementMode`,
|
||||||
|
`PlacementModeMapping`, and `PlacementModeSequenceItem`, preserving underlying
|
||||||
|
`uint8` values and zero semantics. Use the same type throughout engine
|
||||||
|
placement parsing, normalization, acquisition, source evidence, and version
|
||||||
|
projection. Do not add a second raw-mode record or type alias.
|
||||||
|
2. Expose engine `Options[T].Destination` and `.PlacementMode`, and
|
||||||
|
`Input.PlacementMode`. Normalized `loadInput` and other private owner fields
|
||||||
|
can retain their names. Populate the normalized input from those fields.
|
||||||
|
Remove `OptionsWithPlacement` and `InputWithPlacement` and migrate all callers,
|
||||||
|
including the direct engine construction in root `records_test.go`.
|
||||||
|
3. In root `toEngineOptions` and `toEngineInput`, set destination using the existing
|
||||||
|
`toEnginePath` conversion and placement using
|
||||||
|
`engine.PlacementMode(options.PlacementMode)` or its input equivalent.
|
||||||
|
Preserve invalid enum values for `normalizeInput` to diagnose before reads.
|
||||||
|
Do not add validation/defaulting to the facade or a new setter/cast function.
|
||||||
|
4. Preserve normalization of literal/parsed destination keys, root-item rejection,
|
||||||
|
wrapper accounting, mounted/item source coordinates, and version projection.
|
||||||
|
Root `Path` is immutable and `toEnginePath` carries its private engine value;
|
||||||
|
keep normalization and actual mutable ownership copies. Do not redesign path
|
||||||
|
conversion or add redundant clones solely to replace the deleted decorator.
|
||||||
|
5. Leave source-result transport such as `SourceInfoPlacementModeForFacade`,
|
||||||
|
source-version conversion, sequence registration preparation, and opaque
|
||||||
|
records alone. They are not input setters targeted by this step. Adapt tests
|
||||||
|
mechanically to new engine construction without weakening public assertions.
|
||||||
|
|
||||||
|
Acceptance: both public loading APIs retain zero/mapping and sequence-item
|
||||||
|
placement, errors before input consumption, final values, source metadata, and
|
||||||
|
history. Reuse the public placement tests plus destination/accounting,
|
||||||
|
item-import, mounted-version, and invalid-option cases. Root signatures still
|
||||||
|
contain no engine types, and engine exports remain below the internal boundary.
|
||||||
|
|
||||||
|
Focused checks:
|
||||||
|
`go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'` and
|
||||||
|
`go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`.
|
||||||
|
|
||||||
|
## Stage 5 — Integrate, Document, And Verify The Result
|
||||||
|
|
||||||
|
Findings: final acceptance for **C03, F07**. Status: **complete**. Validation
|
||||||
|
passed on Linux/amd64 (Go 1.26.10):
|
||||||
|
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh`,
|
||||||
|
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and
|
||||||
|
`git diff --check`. Existing integration coverage was reviewed; no additional
|
||||||
|
case was needed.
|
||||||
|
|
||||||
|
1. Review both paths together: defaults resolving wildcard discovery in ordinary,
|
||||||
|
layered, and transitive imports; placement carrying the resulting sources
|
||||||
|
through composition and inspection; and failures stopping before later hooks.
|
||||||
|
Existing integration tests own these interactions. Add only a concrete missing
|
||||||
|
case rather than a second copy of the complete matrix or a new fixture framework.
|
||||||
|
2. Inspect the resulting handoffs. Confirm the glob selector no longer embeds
|
||||||
|
per-entry preparation, the engine has one defaults input type, direct fields
|
||||||
|
replaced the four input decorators, and the load snapshot is not repeated at
|
||||||
|
each private representation. Keep domain-specific policy/registration and
|
||||||
|
root conversion boundaries, necessary ownership copies, and existing budgets.
|
||||||
|
3. Update `docs/internal/imports.md` for the selector/preparation split and
|
||||||
|
`docs/internal/overview.md` for defaults conversion, preflight snapshot ownership,
|
||||||
|
and direct placement construction. Keep algorithms private and avoid public
|
||||||
|
performance or copy-count guarantees. No ADR/policy change is needed.
|
||||||
|
4. Replace C03/F07 detail in the first two `docs/roadmap/future.md` entries with
|
||||||
|
short pointers to `internal-boundaries.md`. Retain their heading anchors for
|
||||||
|
existing audit links; leave other future candidates unchanged. Correct old
|
||||||
|
roadmap references that would otherwise mistake this replacement plan for
|
||||||
|
the previous sprint's completed record: use
|
||||||
|
`bb5540b:docs/roadmap/implementation.md` for that historical plan. Do not reopen
|
||||||
|
the prior audit or retire unrelated planning records in this sprint.
|
||||||
|
5. Format changed Go files, finish deferred checks, run the final checks below,
|
||||||
|
and inspect the complete diff for API, dependency, semantics, or scope changes.
|
||||||
|
Record actual results and platform limitations here. Commit focused nonempty
|
||||||
|
units and hand off a clean workspace; do not create empty validation commits.
|
||||||
|
|
||||||
|
Final validation, following the existing development guide:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh
|
||||||
|
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The everyday check includes consumers, documentation links, and public/dependency
|
||||||
|
boundary tests. Confirm those existing tests ran rather than adding redundant
|
||||||
|
final invocations. Report filesystem/platform skips accurately; express macOS
|
||||||
|
validation, CI completion, release publication, extended fuzz campaigns, and
|
||||||
|
benchmarks are not additional acceptance requirements.
|
||||||
|
|
||||||
|
No product or architectural question remains open. The concrete choices above
|
||||||
|
complete the plan without changing the roadmap's public contracts; private
|
||||||
|
formatting and file organization can follow local idiom.
|
||||||
186
docs/roadmap/internal-boundaries.md
Normal file
186
docs/roadmap/internal-boundaries.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Import Discovery And Facade Boundary Simplification
|
||||||
|
|
||||||
|
Status: target defined; execution is tracked in the [implementation plan](implementation.md).
|
||||||
|
Baseline: `80cf23e7102fca2c5d7c7621e75f930a1ef1ecad`.
|
||||||
|
|
||||||
|
This roadmap brings together the two refactors deferred until after the audit
|
||||||
|
remediation sprint: glob entry preparation (**C03**) and facade-to-engine
|
||||||
|
transport simplification (**F07**). Their original motivation is retained in the
|
||||||
|
[glob](future.md#1-separate-glob-entry-preparation-from-traversal) and
|
||||||
|
[transport](future.md#2-simplify-redundant-facade-to-engine-transport) future-work
|
||||||
|
entries and the [consolidated audit](audit.md).
|
||||||
|
|
||||||
|
The goal is clearer responsibility and ownership with fewer redundant internal
|
||||||
|
representations. Both changes preserve the public API and configuration model.
|
||||||
|
They are independent work areas within one maintenance effort, not a new loading
|
||||||
|
workflow. The [implementation plan](implementation.md) owns concrete edits,
|
||||||
|
stages, validation commands, and execution progress.
|
||||||
|
|
||||||
|
## Baseline And Targeted End State
|
||||||
|
|
||||||
|
The preceding sprint repaired directory-failure classification and shared link
|
||||||
|
dependency evaluation, clarified acquisition handoffs, and removed unused
|
||||||
|
placement bridges. Those changes provide the baseline; this work does not repeat
|
||||||
|
or redesign them.
|
||||||
|
|
||||||
|
| Area | Current baseline | Target |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Glob discovery | `selectGlobImportCandidates` interleaves traversal and set operations with substantial per-entry preparation. | The selector coordinates discovery; a cohesive private operation prepares a wildcard-discovered entry. |
|
||||||
|
| Facade transport | Import defaults pass through root conversion, an exported engine transport record, an equivalent private engine record, and repeated snapshots. Placement construction also uses setter-style bridges. | Root-owned public records convert explicitly to one engine defaults representation, with straightforward engine option/input construction and identifiable ownership copies. |
|
||||||
|
|
||||||
|
The [architecture policy](../policy/architecture.md), [configuration
|
||||||
|
contract](../config.md), and public declarations remain authoritative. [ADR
|
||||||
|
0006](../adr/0006-public-facade.md) continues to govern the facade boundary. These
|
||||||
|
refactors do not require a new architectural decision or changes to its accepted
|
||||||
|
decision. The [component overview](../internal/overview.md) and [import
|
||||||
|
internals](../internal/imports.md) own implemented structure.
|
||||||
|
|
||||||
|
## Glob Entry Preparation
|
||||||
|
|
||||||
|
### Target Responsibility Split
|
||||||
|
|
||||||
|
In [glob.go](../../internal/engine/glob.go), the selector continues to own pattern
|
||||||
|
validation, component advancement, literal-segment handling, directory
|
||||||
|
enumeration and its accounting, candidate union and sorting, final target
|
||||||
|
validation, exclusions, and requiredness.
|
||||||
|
|
||||||
|
A private glob-specific preparation operation owns the patterned branch's work
|
||||||
|
for one directory entry: matching and hidden-entry filtering, lexical/access path
|
||||||
|
construction, containment checks, target inspection and discovered-link policy,
|
||||||
|
intermediate-directory eligibility, host/native access-path selection, labels,
|
||||||
|
and candidate construction. Its result distinguishes a selected candidate, an
|
||||||
|
ordinary skip, and a diagnostic. It receives the facts needed for that entry
|
||||||
|
rather than mutating the selector's candidate set or surrounding traversal state.
|
||||||
|
|
||||||
|
Use the existing filesystem access and link-resolution helpers. Directory
|
||||||
|
selection already has `prepareImportDirectoryEntry`; it remains a separate owner
|
||||||
|
because its recursion, matching, absence handling, and traversal state differ.
|
||||||
|
Share a smaller operation only where it actually expresses the same semantics
|
||||||
|
and reduces duplication. No common visitor or discovery strategy is needed for
|
||||||
|
this extraction.
|
||||||
|
|
||||||
|
### Behavior To Preserve
|
||||||
|
|
||||||
|
Preserve the current distinctions and processing order documented by
|
||||||
|
[explicit imports](../config.md#explicit-imports) and exercised by the selection
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Literal components remain explicit selections; discovery filters apply at
|
||||||
|
their existing wildcard boundaries. Moving matching or hidden filtering after
|
||||||
|
filesystem inspection would change which errors are observable.
|
||||||
|
- Keep lexical candidate identity separate from resolved access identity.
|
||||||
|
Native source identity and relative imports retain namespace spelling; host
|
||||||
|
access retains its existing canonicalization and containment behavior.
|
||||||
|
- Keep discovered-link follow rules, intermediate-directory eligibility,
|
||||||
|
missing-target handling, and structured diagnostic sites/classifications.
|
||||||
|
Do not make glob and directory rules identical as a side effect of extraction.
|
||||||
|
- Preserve deterministic candidate and error order, union/deduplication,
|
||||||
|
directory-entry accounting, and the existing point of source admission.
|
||||||
|
Final selected-file validation remains before exclusions where it is today;
|
||||||
|
exclusions do not hide discovery or resource errors.
|
||||||
|
- Retain the preceding sprint's distinction between missing-on-open directories
|
||||||
|
and failures after opening, along with operation-local completed-link reuse.
|
||||||
|
|
||||||
|
The intended benefit is that a maintainer can understand traversal separately
|
||||||
|
from entry eligibility and identity preparation. This is a readability change;
|
||||||
|
it does not select a different search algorithm or claim a speedup.
|
||||||
|
|
||||||
|
## Facade-To-Engine Transport
|
||||||
|
|
||||||
|
### Target Representations
|
||||||
|
|
||||||
|
The root keeps its public `ImportDefaults`, `PlacementMode`, `Options`, `Input`,
|
||||||
|
and `LayeredOptions` types. Explicit conversions remain appropriate at this
|
||||||
|
boundary; consumers must not acquire engine type identity, fields, or methods.
|
||||||
|
|
||||||
|
Inside the engine, consolidate the equivalent `ImportDefaults` transport and
|
||||||
|
private `importDefaults` records into one presence-aware input-defaults
|
||||||
|
representation. Carry that representation through the ordinary and layered
|
||||||
|
options, normalized request, and acquisition policy instead of translating the
|
||||||
|
same five fields between equivalent engine records.
|
||||||
|
|
||||||
|
Keep `resolvedImportPolicy` distinct: it represents concrete per-declaration
|
||||||
|
choices, including selector-specific rules, rather than another copy of optional
|
||||||
|
load-wide input. Likewise, registration compilation and the normalized loading
|
||||||
|
request have real responsibilities and are not replacement targets.
|
||||||
|
|
||||||
|
Make placement and defaults straightforward to construct on the existing engine
|
||||||
|
option/input records. Remove decorator-style bridges made unnecessary by that
|
||||||
|
construction surface. Exporting fields or types within `internal/engine` where
|
||||||
|
needed by the root converter does not make them part of ConfigKit's supported
|
||||||
|
public API. Keep explicit enum conversion and preserve unknown values until the
|
||||||
|
existing option validation can report them; conversion must not silently choose
|
||||||
|
a default or move validation into facade setters.
|
||||||
|
|
||||||
|
### Ownership And Copying
|
||||||
|
|
||||||
|
Make the actual mutable ownership boundaries identifiable. Public load calls
|
||||||
|
continue to snapshot pointed-to default booleans and the pattern slice before
|
||||||
|
source acquisition, as specified by the [public
|
||||||
|
declaration](../../lifecycle.go). Preserve omitted, explicit false, nil pattern,
|
||||||
|
and non-nil empty pattern as distinct inputs where they are distinct today.
|
||||||
|
|
||||||
|
Review the entire handoff, not just the duplicate type definitions: root
|
||||||
|
conversion in [conversion.go](../../conversion.go), ordinary construction in
|
||||||
|
[lifecycle.go](../../lifecycle.go), layered construction in
|
||||||
|
[layered.go](../../layered.go), engine options and normalization, acquisition
|
||||||
|
policy construction, and declaration resolution in
|
||||||
|
[import_spec.go](../../internal/engine/import_spec.go).
|
||||||
|
|
||||||
|
Remove snapshots that merely recopy already-owned read-only defaults as they
|
||||||
|
cross those private representations. Keep copies needed when establishing
|
||||||
|
independent mutable ownership, including any independently owned resolved
|
||||||
|
policy. The target is explainable ownership and less redundant work, not an exact
|
||||||
|
copy count or removal of copying throughout the library. Shared defaults within
|
||||||
|
one load can be read-only by implementation discipline; this creates no new
|
||||||
|
caller immutability or concurrency requirement.
|
||||||
|
|
||||||
|
Preserve ordinary and layered option preflight before source reads and application
|
||||||
|
hooks, independent declaration overrides, transitive/underlay propagation,
|
||||||
|
selected-overlay behavior, destination normalization, and invalid-enum
|
||||||
|
diagnostics. Keep callback, source, path, secret, and returned-record conversions
|
||||||
|
whose ownership or domain meaning is not redundant transport.
|
||||||
|
|
||||||
|
The intended benefit is fewer concepts, field mappings, and construction paths
|
||||||
|
to maintain. Reduced copying is useful but does not establish an end-to-end
|
||||||
|
performance guarantee.
|
||||||
|
|
||||||
|
## Documentation Ownership
|
||||||
|
|
||||||
|
Internal documentation describes the resulting discovery and transport
|
||||||
|
boundaries once implemented. Public declarations continue to own the existing
|
||||||
|
snapshot and type contracts; this effort selects no new public behavior.
|
||||||
|
The corresponding C03/F07 future-work entries serve as navigation to this
|
||||||
|
roadmap rather than parallel target or progress inventories. Unrelated future
|
||||||
|
candidates remain separate. The implementation plan owns the documentation
|
||||||
|
updates and retirement of replaced private representations and helpers.
|
||||||
|
|
||||||
|
## Validation And Completion
|
||||||
|
|
||||||
|
Follow the [testing policy](../policy/testing.md) and [development
|
||||||
|
guide](../development.md#baseline-validation). Reuse the existing glob selection,
|
||||||
|
directory/link failure, import-default, placement, public facade, and dependency
|
||||||
|
boundary coverage. Add a focused behavioral case only where a concrete
|
||||||
|
extraction or ownership risk lacks protection.
|
||||||
|
|
||||||
|
For discovery, verify literal versus wildcard filters, host/native identity,
|
||||||
|
contained and escaping links, optional absence versus read/close failures,
|
||||||
|
ordering, exclusions, regular-file checks, and existing resource accounting. For
|
||||||
|
transport, verify nil/empty and explicit-false distinctions, mutation of caller
|
||||||
|
default storage after the load snapshot through a supported callback, invalid
|
||||||
|
options before input consumption, both loading APIs, and transitive/underlay
|
||||||
|
defaults. Existing conversion-focused tests may change with private construction;
|
||||||
|
preserve the useful ownership behavior rather than fossilizing a helper's shape.
|
||||||
|
|
||||||
|
Completion requires an easier-to-follow selector, one engine input-defaults
|
||||||
|
representation, fewer redundant mapping/construction sites, justified remaining
|
||||||
|
copies, and preserved public behavior under the relevant checks. Review source
|
||||||
|
and ownership flow as well as passing tests; moving complexity into a helper or
|
||||||
|
renaming a duplicate record alone is insufficient.
|
||||||
|
|
||||||
|
No benchmark campaign, complexity score, allocation threshold, exact filesystem
|
||||||
|
call trace, new resource limit, or additional release gate is required. This work
|
||||||
|
adds no dependencies, caches, background activity, or new consumer controls, and
|
||||||
|
does not reopen other audit findings or deferred performance experiments. Routine
|
||||||
|
helper signatures, names, and file placement belong to the implementation plan;
|
||||||
|
no significant product or architectural decision remains open for this scope.
|
||||||
276
docs/roadmap/roadmap.md
Normal file
276
docs/roadmap/roadmap.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# Audit Remediation Roadmap
|
||||||
|
|
||||||
|
Status: target accepted; completed execution is recorded in the historical plan
|
||||||
|
at `bb5540b:docs/roadmap/implementation.md`.
|
||||||
|
Prepared: 2026-10-25. Planning baseline: `e1e94ee`.
|
||||||
|
|
||||||
|
This roadmap describes the target state and related work for the 29 entries
|
||||||
|
selected in the [consolidated audit review](audit.md#settled-scope-decisions).
|
||||||
|
It is intended for maintainers reviewing the remediation's scope and architecture.
|
||||||
|
|
||||||
|
## Document Ownership
|
||||||
|
|
||||||
|
- [audit.md](audit.md) owns finding IDs, original evidence, detailed remedies and
|
||||||
|
acceptance cases, and the settled scope decisions. Its evidence has not been
|
||||||
|
independently reproduced by writing this roadmap.
|
||||||
|
- This document owns the combined target state, work areas, and design intent.
|
||||||
|
Finding IDs below provide traceability without repeating the full audit inventory.
|
||||||
|
- The historical implementation plan at
|
||||||
|
`bb5540b:docs/roadmap/implementation.md` records concrete stages, private
|
||||||
|
implementation choices, edits, validation commands, and completion evidence.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md), public Go declarations, and
|
||||||
|
[architecture policy](../policy/architecture.md) remain the owners of behavior
|
||||||
|
and guarantees. Update their applicable implementation documentation when work
|
||||||
|
lands; this planning document does not redefine their contracts.
|
||||||
|
|
||||||
|
## Current Baseline And Targeted End State
|
||||||
|
|
||||||
|
The existing loading model remains the foundation. Public loading APIs delegate
|
||||||
|
to the shared engine lifecycle; acquisition retains source scopes for composition,
|
||||||
|
then precedence, inheritance, decoding, CLI overrides, application resolution,
|
||||||
|
secret lookup, and validation produce a detached result with inspection metadata.
|
||||||
|
The [component overview](../internal/overview.md) describes the current owners.
|
||||||
|
|
||||||
|
The audits identify failures at particular traversal, lifetime, error, and
|
||||||
|
accounting boundaries, alongside redundant representations and repeated work.
|
||||||
|
The target is to repair those boundaries and simplify their implementation while
|
||||||
|
preserving ConfigKit's public API and configuration model.
|
||||||
|
|
||||||
|
| Current gap | Targeted outcome |
|
||||||
|
| --- | --- |
|
||||||
|
| Recursive descriptors can fail to terminate, failed dynamic builds can leave partial state, and slice aliases can be mistaken for cycles. | Traversal terminates for the reported cases, failed construction leaves reusable state valid, and supported acyclic values remain accepted. |
|
||||||
|
| Callback and owned-root cleanup can be skipped during panic unwinding; directory errors lose their failure phase. | Cleanup follows the lifetime of each owned resource, and selected-directory failures retain the information needed for correct error handling. |
|
||||||
|
| Finite symlink dependencies can be expanded repeatedly. | Completed dependencies are reused within a resolution while active cycles remain detectable. |
|
||||||
|
| Projection reservations and published history can escape existing accounting. | Existing budgets govern the relevant allocations and complete retained document graph. |
|
||||||
|
| Collection-policy work repeatedly scans or copies unrelated state. | Policy preparation, application, and retained evidence have clear ownership, with work localized to affected policy. |
|
||||||
|
| Acquisition has redundant paths and difficult handoffs; several private representations have no production consumer. | Production paths and their tests share the active protocol, with cohesive handoffs and unused representations removed. |
|
||||||
|
| Some lookups repeat full scans, and some tests assert incidental representation or miss meaningful mutations. | Local indexing avoids the identified repeated work, and tests protect observable contracts at useful boundaries. |
|
||||||
|
|
||||||
|
This is a bounded remediation effort, not a redesign of configuration semantics.
|
||||||
|
Correcting erroneous acceptance, rejection, cleanup, or diagnostics in the
|
||||||
|
reported cases restores existing contracts. It does not select new loading APIs,
|
||||||
|
configuration options, limits, background work, or persistent caches.
|
||||||
|
|
||||||
|
## Selected Work
|
||||||
|
|
||||||
|
The following areas account for all 29 selected entries. Detailed examples and
|
||||||
|
acceptance cases remain with their linked audit group.
|
||||||
|
|
||||||
|
### 1. Safe Typed-Value Traversal And Descriptor Reuse
|
||||||
|
|
||||||
|
Selected entries: **A01, A02, A03, A04, A05** in
|
||||||
|
[schema and typed values](audit.md#a-schema-and-typed-values).
|
||||||
|
|
||||||
|
Make pointer-only descriptor traversal terminate in decoding and path resolution,
|
||||||
|
while preserving supported null values and ordinary recursive structures. Make
|
||||||
|
dynamic descriptor construction transactional: a failed build must not leave
|
||||||
|
partial descriptors available to subsequent operations. Staging successful
|
||||||
|
additions or rolling back the complete failed addition set are implementation
|
||||||
|
choices; both must handle recursive references and preserve prior valid entries.
|
||||||
|
|
||||||
|
Correct active-path cycle detection to distinguish finite slice views sharing
|
||||||
|
storage from actual cycles. Keep detachment and nil/empty behavior intact across
|
||||||
|
the callers that share this traversal machinery.
|
||||||
|
|
||||||
|
Enforce existing projection budgets before large container reservations. Include
|
||||||
|
map-key materialization and overflow-safe lower-bound checks in the review; a
|
||||||
|
check after the reservation does not address the reported failure. This work
|
||||||
|
does not introduce a general heap limit or reject large containers independently
|
||||||
|
of existing budgets.
|
||||||
|
|
||||||
|
Dynamic-type eligibility reuse depends on safe descriptor construction and retains
|
||||||
|
completed successful checks within the value operation. Keep failure diagnostics
|
||||||
|
specific to their current path and distinguish completed results from traversal visits.
|
||||||
|
This removes repeated descriptor-graph walks without a cross-load cache.
|
||||||
|
|
||||||
|
### 2. Callback, Filesystem, And Dependency Lifetimes
|
||||||
|
|
||||||
|
Selected entries: **B01, B02** in [callback lifetime](audit.md#b-callback-and-filesystem-lifetime)
|
||||||
|
and **C01, C02** in [filesystem traversal](audit.md#c-import-selection-and-link-resolution).
|
||||||
|
|
||||||
|
Tie callback-context deactivation to the callback invocation with deferred cleanup
|
||||||
|
so it runs on normal return and panic unwinding. Independently give the owned
|
||||||
|
import root a deferred cleanup fallback. Preserve normal close-error handling,
|
||||||
|
caller ownership, and propagation of the original callback panic. Fixing either
|
||||||
|
state lifetime or handle lifetime does not fix the other.
|
||||||
|
|
||||||
|
Preserve the phase of a directory failure. A genuinely absent directory before
|
||||||
|
successful opening remains subject to existing permissiveness rules; a later
|
||||||
|
read or close failure must not be treated as absence. Apply this distinction to
|
||||||
|
both supported directory-reading paths. Strengthen the shared filesystem tests
|
||||||
|
to check each handle's closure and structured, non-disclosing diagnostics,
|
||||||
|
including partial reads and failures masked by another successful directory.
|
||||||
|
Do not change regular-file close-error policy as a side effect.
|
||||||
|
|
||||||
|
Avoid repeatedly expanding completed finite symlink dependencies in both host
|
||||||
|
and native filesystem resolution. Use operation-local memoization of completed
|
||||||
|
targets, or an equivalent dependency evaluation, with keys that respect the
|
||||||
|
resolution namespace. Keep active-path cycle detection separate from completed
|
||||||
|
work. Preserve containment, supported finite reuse, and existing host-path
|
||||||
|
semantics. The fix must work for directory and glob selection without a new
|
||||||
|
arbitrary traversal cap or changed symlink defaults.
|
||||||
|
|
||||||
|
### 3. Retained History And Combination Evidence
|
||||||
|
|
||||||
|
Selected entries: **D01, D02, D03, D04** in
|
||||||
|
[history and retained state](audit.md#d-retained-history-and-unused-evidence).
|
||||||
|
|
||||||
|
Remove the unconsumed override-result pre-CLI copy and the unqueried published
|
||||||
|
structural-input copy. Preserve the separate pre-CLI state used by active replay
|
||||||
|
and the structural input still used before publication for metadata and secret
|
||||||
|
classification. Remove unused combination-evidence members and the work needed
|
||||||
|
solely to produce them, retaining projections, mappings, origins, and snapshots
|
||||||
|
that have active consumers.
|
||||||
|
|
||||||
|
With retained roots clarified, enforce the existing history allowance against
|
||||||
|
the complete graph published through inspection, including replay and inheritance
|
||||||
|
state and publication-created copies. Count shared nodes once by pointer identity;
|
||||||
|
distinct retained copies still consume the allowance. Account for required copies
|
||||||
|
before creating them where practical. Removing an unused tree alone does not
|
||||||
|
resolve the accounting omissions.
|
||||||
|
|
||||||
|
Keep required before/after evidence and existing inspection behavior. Validate
|
||||||
|
the complete publication path with small controlled allowances, including the
|
||||||
|
interaction of CLI, resolution replay, and inheritance, rather than relying only
|
||||||
|
on tests of an isolated counting helper.
|
||||||
|
|
||||||
|
### 4. Collection-Policy Preparation And Application
|
||||||
|
|
||||||
|
Selected entries: **E01, E02, E03** in
|
||||||
|
[collection policy and projection](audit.md#e-collection-processing-and-efficiency).
|
||||||
|
|
||||||
|
Distinguish the existing policy state used for lookup, changes introduced by an
|
||||||
|
incoming tier, and evidence retained by the assembly. Prepare incoming claims and
|
||||||
|
declarations against read-only lower state, then apply the owned changes at the
|
||||||
|
appropriate combination boundary. Avoid copying every unrelated policy for
|
||||||
|
small tiers and scanning all policies for each independent replacement.
|
||||||
|
|
||||||
|
Use a semantic-path index, localized updates, or equivalent bounded lookup to
|
||||||
|
find affected policy. Preserve retirement timing, policy established by the
|
||||||
|
incoming contribution, pending declarations, persistent explicit modes, and
|
||||||
|
nested record ownership. Copying remains appropriate wherever mutable ownership
|
||||||
|
requires it. Removing only one duplicate clone or shortening a function does not
|
||||||
|
address the identified growth patterns.
|
||||||
|
|
||||||
|
Clarify preparation, evidence application, and finalization responsibilities.
|
||||||
|
Share straightforward history-descriptor translation where useful; evaluate
|
||||||
|
shared record-selection logic while keeping overlay and inheritance origin and
|
||||||
|
mode lifetimes distinct. Retain the common sequence-combination owner. This work
|
||||||
|
does not call for a generic merging framework.
|
||||||
|
|
||||||
|
### 5. Active Acquisition Handoffs And Private Cleanup
|
||||||
|
|
||||||
|
Selected entries: **F01, F02, F03, F04, F05, F06** in
|
||||||
|
[acquisition, cleanup, and test quality](audit.md#f-remaining-maintenance-and-test-improvements).
|
||||||
|
|
||||||
|
Remove the test-only eager acquisition mode so acquisition retains scopes through
|
||||||
|
the active production protocol. Tests that need composition should invoke the
|
||||||
|
actual composition boundary explicitly. Retire the parallel test-only import
|
||||||
|
parser and route relevant fixtures through the active directive parser or the
|
||||||
|
appropriate declaration boundary, retaining malformed-input coverage.
|
||||||
|
|
||||||
|
Clarify active acquisition and coordinator handoffs around cohesive tasks:
|
||||||
|
parsed-input directive/version handling, standalone placement, and repeated tier
|
||||||
|
application/projection. Use explicit inputs and results consistent with existing
|
||||||
|
state owners. Extracting helpers that continue to share surrounding mutable state
|
||||||
|
does not establish a clearer handoff. Preserve ordinary/layered differences,
|
||||||
|
overlay and diff behavior, source identity, callback timing, diagnostics, and
|
||||||
|
accounting scopes.
|
||||||
|
|
||||||
|
Remove the disconnected root record-clone family, uncalled engine helpers, and
|
||||||
|
unread private transport/result members identified in the audit. Reconfirm
|
||||||
|
references on the implementation baseline, including function values and relevant
|
||||||
|
build configurations. Preserve live conversion paths and validation side effects;
|
||||||
|
the audit's absence of field reads is not a general rule for deleting state.
|
||||||
|
Replace meaningful ownership coverage through the public boundary before removing
|
||||||
|
tests that only exercise disconnected helpers.
|
||||||
|
|
||||||
|
### 6. Local Lookup Improvements
|
||||||
|
|
||||||
|
Selected entries: **E04, E05** in
|
||||||
|
[collection policy and projection](audit.md#e-collection-processing-and-efficiency)
|
||||||
|
and **F08** in [secret-failure lookup](audit.md#f08-index-secret-failure-location-queries-lazily).
|
||||||
|
|
||||||
|
Reuse a local document lookup or equivalent traversal for tier-history projection
|
||||||
|
instead of rescanning wide mappings for each sequence. Preserve authored
|
||||||
|
coordinates, sparse projections, and evidence ownership.
|
||||||
|
|
||||||
|
Restore compound identity fields using indexed mapping positions and membership
|
||||||
|
checks rather than rescanning child and merged mappings for every field. Preserve
|
||||||
|
child-authored values and locations, mapping order, and detached nodes.
|
||||||
|
|
||||||
|
Build secret-failure location lookup state lazily when failures need it. Preserve
|
||||||
|
all diagnostics, nearest-location fallbacks, and confidentiality; successful
|
||||||
|
loads should not incur an unnecessary index merely to support failure reporting.
|
||||||
|
These changes address specific repeated work and do not promise a measured
|
||||||
|
startup improvement.
|
||||||
|
|
||||||
|
### 7. Tests That Protect The Intended Behavior
|
||||||
|
|
||||||
|
Selected entries: **F09, F10, F11, F12** in
|
||||||
|
[acquisition, cleanup, and test quality](audit.md#f-remaining-maintenance-and-test-improvements),
|
||||||
|
in addition to the regression coverage attached to each reported defect.
|
||||||
|
|
||||||
|
Cover historical-path enumeration through the public API, including removed
|
||||||
|
paths, queryability, ordering, and detached returned slices. Use those assertions
|
||||||
|
to replace private representation checks where they address the same risk.
|
||||||
|
|
||||||
|
Strengthen fuzz detachment checks by mutating existing descendants or in-length
|
||||||
|
elements, so spare capacity cannot hide aliases. Replace exact filesystem stat
|
||||||
|
call sequences with observable candidate ordering and meaningful failure
|
||||||
|
selection, retaining relevant assertions such as not opening excluded inputs.
|
||||||
|
|
||||||
|
Consolidate facade scaffolding around public construction, loading, errors,
|
||||||
|
ownership, and lifecycle behavior. Preserve distinct integration risks and
|
||||||
|
reader-byte snapshot coverage. Existing coverage percentages or a desired test
|
||||||
|
count are not acceptance criteria for this work.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
The historical implementation plan at `bb5540b:docs/roadmap/implementation.md`
|
||||||
|
defines the sequential, Luna-scoped stages and their dependencies, including
|
||||||
|
coordinated changes to descriptor state, history ownership/accounting, collection
|
||||||
|
policy, acquisition, and replacement public coverage. It also records validation
|
||||||
|
commands and the complete finding-to-stage map. The audit remains the owner of
|
||||||
|
original evidence and detailed acceptance cases.
|
||||||
|
|
||||||
|
## Validation And Completion
|
||||||
|
|
||||||
|
Validation follows the audit's detailed acceptance cases and the
|
||||||
|
[testing policy](../policy/testing.md). For fatal or hanging regressions, use bounded
|
||||||
|
subprocesses; for budget cases, use small controlled limits. For symlink expansion
|
||||||
|
and repeated lookup work, prefer bounded operation evidence and algorithm review
|
||||||
|
over timing thresholds. Use real filesystem fixtures where filesystem semantics
|
||||||
|
matter and narrow fault injection for read/close failures. State platform limits.
|
||||||
|
|
||||||
|
Applicable development checks come from the [development guide](../development.md),
|
||||||
|
including appropriate race coverage for changed ownership and lifetime boundaries.
|
||||||
|
The historical implementation plan owns their execution. Existing audit results
|
||||||
|
are baseline evidence, not validation of the eventual changes. No performance
|
||||||
|
benchmark campaign is required by this roadmap, and no new benchmark results are
|
||||||
|
claimed.
|
||||||
|
|
||||||
|
Completion means all 29 selected findings have implementation and acceptance
|
||||||
|
evidence, the affected public behavior remains consistent with its contracts,
|
||||||
|
and canonical internal documentation describes the resulting owners. The
|
||||||
|
historical implementation plan records execution status without duplicate
|
||||||
|
progress inventories. Retire completed planning records under the normal
|
||||||
|
documentation lifecycle.
|
||||||
|
|
||||||
|
## Scope Boundaries And Decision Status
|
||||||
|
|
||||||
|
All three audit scope questions are settled. No significant architectural or
|
||||||
|
product decision remains open for this roadmap. The historical implementation
|
||||||
|
plan records private design choices and sequencing, subject to the acceptance
|
||||||
|
cases above.
|
||||||
|
If that work exposes a need to change a public contract, bring the specific
|
||||||
|
decision back for review rather than treating it as an implicit part of remediation.
|
||||||
|
|
||||||
|
The glob preparation refactor (C03) and wider facade transport simplification
|
||||||
|
(F07) have since been selected in the
|
||||||
|
[internal-boundary roadmap](internal-boundaries.md). Other deferred features,
|
||||||
|
clone-traversal redesign, and broader performance experiments remain outside
|
||||||
|
this effort. Keep
|
||||||
|
existing ownership boundaries and loading APIs; no ADR or architecture-policy
|
||||||
|
change is proposed by this roadmap.
|
||||||
@@ -19,6 +19,28 @@ func ExampleLoad() {
|
|||||||
// Output: {true 3} <nil>
|
// Output: {true 3} <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExampleLoad_sequenceDeclaration() {
|
||||||
|
type Item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
type Config struct {
|
||||||
|
Items []Item `yaml:"items"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [items]
|
||||||
|
key: id
|
||||||
|
items:
|
||||||
|
- id: prompt
|
||||||
|
`)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Items[0].ID)
|
||||||
|
// Output: prompt
|
||||||
|
}
|
||||||
|
|
||||||
func ExampleNoSource() {
|
func ExampleNoSource() {
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Workers int `yaml:"workers"`
|
Workers int `yaml:"workers"`
|
||||||
@@ -154,7 +176,14 @@ func ExampleOverride() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
||||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
Definition: configkit.Definition[Config]{
|
||||||
|
Defaults: func() Config { return Config{Workers: 4} },
|
||||||
|
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
|
||||||
|
configkit.Default(resolution, configkit.Keys("workers"), &configuration.Workers, 100,
|
||||||
|
"preserve an explicitly supplied worker count")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
Overrides: overrides,
|
Overrides: overrides,
|
||||||
})
|
})
|
||||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||||
|
|||||||
45
examples/consumers/discovery_test.go
Normal file
45
examples/consumers/discovery_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workingDirectoryPresetConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_workingDirectoryPreset() {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(original) }()
|
||||||
|
root, err := os.MkdirTemp("", "configkit-working-search-")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(root)
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[workingDirectoryPresetConfig](
|
||||||
|
configkit.SearchConfig("service", configkit.WorkingDirectory()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
|
||||||
|
// Output: example service.yml
|
||||||
|
}
|
||||||
@@ -16,9 +16,10 @@ type PromptRecord struct {
|
|||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Messages []string `yaml:"messages"`
|
Messages []string `yaml:"messages"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadPromptCatalog composes explicitly registered prompt records in the main
|
// LoadPromptCatalog discovers explicitly registered prompt records in the main
|
||||||
// tier and replaces the lower-priority default collection as a whole.
|
// tier and replaces the lower-priority default collection as a whole.
|
||||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||||
return configkit.LoadLayered(configkit.Input{
|
return configkit.LoadLayered(configkit.Input{
|
||||||
@@ -28,16 +29,29 @@ func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig],
|
|||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||||
}}}},
|
}}}},
|
||||||
Definition: configkit.Definition[PromptCatalogConfig]{
|
ImportDefaults: configkit.ImportDefaults{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{
|
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||||
Path: configkit.Keys("prompts"),
|
|
||||||
IdentityField: "id",
|
|
||||||
}},
|
|
||||||
Validate: validatePromptCatalog,
|
|
||||||
},
|
},
|
||||||
|
Definition: promptCatalogDefinition(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
|
||||||
|
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||||
|
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
|
||||||
|
Imports: true, Definition: promptCatalogDefinition(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||||
|
return configkit.Definition[PromptCatalogConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
},
|
||||||
|
Validate: validatePromptCatalog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
||||||
for index, prompt := range configuration.Prompts {
|
for index, prompt := range configuration.Prompts {
|
||||||
if strings.TrimSpace(prompt.Name) == "" {
|
if strings.TrimSpace(prompt.Name) == "" {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package consumers_test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
pathpkg "path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -20,8 +22,26 @@ func Example_identifiedPromptCatalog() {
|
|||||||
for _, prompt := range result.Config.Prompts {
|
for _, prompt := range result.Config.Prompts {
|
||||||
ids = append(ids, prompt.ID)
|
ids = append(ids, prompt.ID)
|
||||||
}
|
}
|
||||||
fmt.Println(ids, err)
|
emptySearches := 0
|
||||||
// Output: [main curated weather welcome] <nil>
|
for _, source := range result.Sources() {
|
||||||
|
for _, selection := range source.ImportSelections {
|
||||||
|
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
||||||
|
emptySearches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(ids, emptySearches, err)
|
||||||
|
// Output: [weather curated welcome main] 1 <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_requiredPromptCatalog() {
|
||||||
|
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Prompts[0].ID, err)
|
||||||
|
// Output: required <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||||
@@ -36,7 +56,7 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
if len(result.Config.Prompts) != 4 {
|
if len(result.Config.Prompts) != 4 {
|
||||||
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
||||||
}
|
}
|
||||||
ids := []string{"main", "curated", "weather", "welcome"}
|
ids := []string{"weather", "curated", "welcome", "main"}
|
||||||
for index, id := range ids {
|
for index, id := range ids {
|
||||||
if result.Config.Prompts[index].ID != id {
|
if result.Config.Prompts[index].ID != id {
|
||||||
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
||||||
@@ -46,12 +66,15 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
if prompt.ID == "default" {
|
if prompt.ID == "default" {
|
||||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
||||||
}
|
}
|
||||||
|
if prompt.ID == "ignored" {
|
||||||
|
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemImports, helperImports int
|
var itemImports, helperImports int
|
||||||
for _, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
switch filepath.Base(source.Path) {
|
switch filepath.Base(source.Path) {
|
||||||
case "curated.yml", "generated-weather.yml", "generated-welcome.yml":
|
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||||
t.Fatalf("item source placement = %#v", source)
|
t.Fatalf("item source placement = %#v", source)
|
||||||
}
|
}
|
||||||
@@ -71,6 +94,36 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
|
||||||
|
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
|
||||||
|
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
|
||||||
|
}
|
||||||
|
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
|
||||||
|
info, ok := result.Metadata().Lookup(resourcePath)
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("resource origin = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
|
||||||
|
t.Fatalf("resource source context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
|
||||||
|
if err != nil || string(contents) != "Welcome resource text.\n" {
|
||||||
|
t.Fatalf("mounted resource = %q, %v", contents, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
|
||||||
|
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||||
|
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
|
||||||
|
t.Fatalf("simple required prompt = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
||||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
|
|||||||
145
examples/consumers/sequence_catalog.go
Normal file
145
examples/consumers/sequence_catalog.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SequenceCatalogConfig is a source-backed prompt catalog with an application
|
||||||
|
// supplied run label and a value derived from that label during resolution.
|
||||||
|
type SequenceCatalogConfig struct {
|
||||||
|
RunLabel string `yaml:"run_label"`
|
||||||
|
ResolvedLabel string `yaml:"resolved_label"`
|
||||||
|
Prompts []SequenceCatalogEntry `yaml:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SequenceCatalogEntry keeps prompt meaning, resources, and credentials in the
|
||||||
|
// consuming application while ConfigKit handles collection assembly.
|
||||||
|
type SequenceCatalogEntry struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Messages []string `yaml:"messages"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSequenceCatalog searches its caller-selected main path before a custom
|
||||||
|
// fallback directory, assembles imported fragments over a file-backed underlay,
|
||||||
|
// applies one selected collection overlay, then resolves and validates once.
|
||||||
|
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
||||||
|
absoluteMain, err := filepath.Abs(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root := filepath.Dir(absoluteMain)
|
||||||
|
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||||
|
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||||
|
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
||||||
|
configkit.Paths(absoluteMain),
|
||||||
|
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
||||||
|
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
||||||
|
}),
|
||||||
|
), configkit.SearchOptions{Required: true})
|
||||||
|
definition := configkit.Definition[SequenceCatalogConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
||||||
|
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
||||||
|
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization,
|
||||||
|
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: validateSequenceCatalog,
|
||||||
|
}
|
||||||
|
return configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: mainSearch,
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{Root: root},
|
||||||
|
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: filesystem.File("underlay.yml"),
|
||||||
|
}}}},
|
||||||
|
Overlay: overlayName,
|
||||||
|
OverlayFilesystem: filesystemContext,
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("run_label"), Value: runLabel, Flag: "--run-label",
|
||||||
|
}},
|
||||||
|
Definition: definition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSequenceCatalogResource follows the final prompt field's source context,
|
||||||
|
// including when record inheritance retained a resource from an underlay.
|
||||||
|
func ReadSequenceCatalogResource(result *configkit.Result[SequenceCatalogConfig], promptID string) (string, error) {
|
||||||
|
if result == nil {
|
||||||
|
return "", errors.New("sequence catalog result is nil")
|
||||||
|
}
|
||||||
|
for index, prompt := range result.Config.Prompts {
|
||||||
|
if prompt.ID != promptID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resourcePath := configkit.Keys("prompts").Index(index).Key("resource")
|
||||||
|
info, found := result.Metadata().Lookup(resourcePath)
|
||||||
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
|
return "", errors.New("prompt resource has no single declaring source")
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found {
|
||||||
|
return "", errors.New("prompt resource source context is unavailable")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.Resource))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read prompt resource: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("prompt %q is unavailable", promptID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSequenceCatalog(configuration *SequenceCatalogConfig, validation *configkit.Validation) {
|
||||||
|
for index, prompt := range configuration.Prompts {
|
||||||
|
if strings.TrimSpace(prompt.Name) == "" {
|
||||||
|
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
|
||||||
|
}
|
||||||
|
if len(prompt.Messages) == 0 {
|
||||||
|
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(configuration.Prompts) == 0 {
|
||||||
|
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamespacedMapCatalogConfig demonstrates typed application maps whose
|
||||||
|
// independent prompt sequences use root and dynamic namespace registrations.
|
||||||
|
type NamespacedMapCatalogConfig struct {
|
||||||
|
Prompts []map[string]any `yaml:"prompts"`
|
||||||
|
Collections map[string]map[string]any `yaml:"collections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadNamespacedMapCatalog applies the same scalar identity constraint to one
|
||||||
|
// root collection and each namespace's independently scoped collection.
|
||||||
|
func LoadNamespacedMapCatalog(source configkit.Source) (*configkit.Result[NamespacedMapCatalogConfig], error) {
|
||||||
|
selector := configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}
|
||||||
|
return configkit.Load(source, configkit.Options[NamespacedMapCatalogConfig]{
|
||||||
|
Definition: configkit.Definition[NamespacedMapCatalogConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
configkit.SequenceIdentityMatching(selector, "id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
239
examples/consumers/sequence_catalog_test.go
Normal file
239
examples/consumers/sequence_catalog_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_sequenceCatalog() {
|
||||||
|
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||||
|
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||||
|
restoreOld, restoreNew := preserveEnvironment(oldSecret), preserveEnvironment(newSecret)
|
||||||
|
defer restoreOld()
|
||||||
|
defer restoreNew()
|
||||||
|
_ = os.Setenv(oldSecret, "disposable-old-example-credential")
|
||||||
|
_ = os.Setenv(newSecret, "disposable-new-example-credential")
|
||||||
|
|
||||||
|
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "preview")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(result.Config.Prompts))
|
||||||
|
for _, prompt := range result.Config.Prompts {
|
||||||
|
ids = append(ids, prompt.ID)
|
||||||
|
}
|
||||||
|
shared := configkit.Keys("prompts").Index(1).Key("name")
|
||||||
|
info, _ := result.Metadata().Lookup(shared)
|
||||||
|
resource, resourceErr := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||||
|
fmt.Println(ids, result.Config.ResolvedLabel, info.Explicit, len(result.Metadata().History(shared)), resource, resourceErr == nil, err)
|
||||||
|
// Output: [foundation shared welcome concise extra] for:preview true 2 Foundation resource text.
|
||||||
|
// true <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceCatalogSelectedOverlayOperations(t *testing.T) {
|
||||||
|
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||||
|
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||||
|
t.Setenv(oldSecret, "sequence-catalog-old-canary")
|
||||||
|
t.Setenv(newSecret, "sequence-catalog-new-canary")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wantIDs []string
|
||||||
|
shared *consumers.SequenceCatalogEntry
|
||||||
|
}{
|
||||||
|
{name: "append", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}},
|
||||||
|
{name: "upsert", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}, shared: &consumers.SequenceCatalogEntry{ID: "shared", Name: "Shared replacement", Messages: []string{"Replacement message"}, Credential: configkit.SecretEnv(newSecret)}},
|
||||||
|
{name: "replace", wantIDs: []string{"replacement", "last"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), test.name, "test")
|
||||||
|
if err != nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load selected overlay: %v (%#v)", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotIDs := make([]string, 0, len(result.Config.Prompts))
|
||||||
|
for _, prompt := range result.Config.Prompts {
|
||||||
|
gotIDs = append(gotIDs, prompt.ID)
|
||||||
|
}
|
||||||
|
if strings.Join(gotIDs, ",") != strings.Join(test.wantIDs, ",") {
|
||||||
|
t.Fatalf("prompt order = %v, want %v", gotIDs, test.wantIDs)
|
||||||
|
}
|
||||||
|
if test.shared == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared := result.Config.Prompts[1]
|
||||||
|
if shared.ID != test.shared.ID || shared.Name != test.shared.Name || len(shared.Messages) != 1 ||
|
||||||
|
shared.Messages[0] != test.shared.Messages[0] || shared.Description != "" || shared.Resource != "" || shared.Credential.EnvName() != newSecret {
|
||||||
|
t.Fatalf("upsert did not replace the complete matched record: %#v", shared)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceCatalogInspectionTracksResourcesAndSecretHistory(t *testing.T) {
|
||||||
|
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||||
|
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||||
|
const oldCanary = "sequence-catalog-old-canary"
|
||||||
|
const newCanary = "sequence-catalog-new-canary"
|
||||||
|
t.Setenv(oldSecret, oldCanary)
|
||||||
|
t.Setenv(newSecret, newCanary)
|
||||||
|
|
||||||
|
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "history")
|
||||||
|
if err != nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load sequence catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resourcePath := configkit.Keys("prompts").Index(3).Key("resource")
|
||||||
|
resourceInfo, found := result.Metadata().Lookup(resourcePath)
|
||||||
|
if !found || len(resourceInfo.Origin.Locations) != 1 || len(resourceInfo.Origin.Inheritance) != 1 {
|
||||||
|
t.Fatalf("inherited resource inspection = %#v, %v", resourceInfo, found)
|
||||||
|
}
|
||||||
|
resource, err := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||||
|
if err != nil || resource != "Foundation resource text.\n" {
|
||||||
|
t.Fatalf("resource from inherited source context = %q, %v", resource, err)
|
||||||
|
}
|
||||||
|
if len(result.Metadata().History(configkit.Keys("resolved_label"))) == 0 {
|
||||||
|
t.Fatal("recorded CLI-driven resolution was absent from final inspection")
|
||||||
|
}
|
||||||
|
credentialPath := configkit.Keys("prompts").Index(1).Key("credential")
|
||||||
|
credentialHistory := result.Metadata().History(credentialPath)
|
||||||
|
if len(credentialHistory) == 0 {
|
||||||
|
t.Fatal("upsert did not retain the replaced secret reference in history")
|
||||||
|
}
|
||||||
|
var explanation bytes.Buffer
|
||||||
|
if err := result.WriteExplanation(&explanation, credentialPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.String(), oldCanary) || strings.Contains(explanation.String(), newCanary) {
|
||||||
|
t.Fatalf("secret explanation exposed a credential value: %s", explanation.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacedTypedMapIdentityScopesAreIndependent(t *testing.T) {
|
||||||
|
source := configkit.Bytes("namespaced maps", []byte(`
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: root prompt
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: team prompt
|
||||||
|
operations:
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: operations prompt
|
||||||
|
`))
|
||||||
|
result, err := consumers.LoadNamespacedMapCatalog(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Prompts[0]["text"] != "root prompt" ||
|
||||||
|
result.Config.Collections["team"]["prompts"].([]any)[0].(map[string]any)["text"] != "team prompt" ||
|
||||||
|
result.Config.Collections["operations"]["prompts"].([]any)[0].(map[string]any)["text"] != "operations prompt" {
|
||||||
|
t.Fatalf("typed map collections = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacedTypedMapIdentityFindsCollectionsIntroducedByInheritance(t *testing.T) {
|
||||||
|
result, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("inherited namespace", []byte(`
|
||||||
|
collections:
|
||||||
|
source:
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: inherited prompt
|
||||||
|
copied:
|
||||||
|
extends: source
|
||||||
|
`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
copied := result.Config.Collections["copied"]["prompts"].([]any)
|
||||||
|
if len(copied) != 1 || copied[0].(map[string]any)["text"] != "inherited prompt" {
|
||||||
|
t.Fatalf("inherited typed-map collection = %#v", result.Config.Collections["copied"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_yamlDeclaredIdentityOnly() {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
prompts:
|
||||||
|
- id: initial
|
||||||
|
`)), configkit.Options[map[string]any]{
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("prompts"),
|
||||||
|
Value: []any{
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
},
|
||||||
|
Flag: "--prompts",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
fmt.Println(len(result.Config["prompts"].([]any)), err)
|
||||||
|
// Output: 2 <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
prompts:
|
||||||
|
- id: initial
|
||||||
|
`)), configkit.Options[map[string]any]{
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("prompts"),
|
||||||
|
Value: []any{
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
},
|
||||||
|
Flag: "--prompts",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("YAML-only identity incorrectly became an application constraint: %v", err)
|
||||||
|
}
|
||||||
|
prompts := result.Config["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 || prompts[0].(map[string]any)["id"] != prompts[1].(map[string]any)["id"] {
|
||||||
|
t.Fatalf("CLI result = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacedTypedMapRejectsDuplicatesWithinOneCollection(t *testing.T) {
|
||||||
|
_, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("duplicate namespace", []byte(`
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts:
|
||||||
|
- id: duplicate
|
||||||
|
- id: duplicate
|
||||||
|
`)))
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||||
|
loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict ||
|
||||||
|
loadError.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||||
|
diagnostics := []configkit.Diagnostic(nil)
|
||||||
|
if loadError != nil {
|
||||||
|
diagnostics = loadError.Diagnostics()
|
||||||
|
}
|
||||||
|
t.Fatalf("duplicate namespaced identity error = %v (%#v)", err, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
configkit:
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
imports:
|
imports:
|
||||||
- path: prompts/curated.yml
|
- dir: prompts
|
||||||
|
exclude: [generated-ignored.yml]
|
||||||
destination: [prompts]
|
destination: [prompts]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
- glob: prompts/generated-*.yml
|
- dir: featured
|
||||||
|
pattern: [weather-*.yml]
|
||||||
destination: [prompts]
|
destination: [prompts]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
|
- dir: optional
|
||||||
prompts:
|
prompts:
|
||||||
- id: main
|
- id: main
|
||||||
name: Main authored prompt
|
name: Main authored prompt
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
prompts:
|
prompts:
|
||||||
- id: invalid
|
- id: invalid
|
||||||
name: Invalid prompt
|
name: Invalid prompt
|
||||||
|
|||||||
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This file does not match the application import defaults.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: curated-id.yml
|
- path: helpers/curated-id.yml
|
||||||
name: Curated summary
|
name: Curated summary
|
||||||
messages:
|
messages:
|
||||||
- Summarize the supplied content.
|
- Summarize the supplied content.
|
||||||
|
|||||||
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: ignored
|
||||||
|
name: Excluded generated prompt
|
||||||
|
messages: [This record is excluded.]
|
||||||
@@ -2,3 +2,4 @@ id: welcome
|
|||||||
name: Welcome
|
name: Welcome
|
||||||
messages:
|
messages:
|
||||||
- Welcome the user.
|
- Welcome the user.
|
||||||
|
resource: welcome.txt
|
||||||
|
|||||||
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: required
|
||||||
|
name: Required prompt
|
||||||
|
messages: [Use the required prompt.]
|
||||||
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome resource text.
|
||||||
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: prompts/required.yml
|
||||||
|
required: true
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
prompts: []
|
||||||
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
run_label: source-default
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- dir: fragments
|
||||||
|
pattern: '*.yml'
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Foundation resource text.
|
||||||
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: welcome
|
||||||
|
name: Welcome
|
||||||
|
messages: [Welcome message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: concise
|
||||||
|
extends: {id: foundation}
|
||||||
|
name: Concise
|
||||||
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: append
|
||||||
|
prompts:
|
||||||
|
- id: extra
|
||||||
|
name: Extra
|
||||||
|
messages: [Extra message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
|
prompts:
|
||||||
|
- id: replacement
|
||||||
|
name: Replacement
|
||||||
|
messages: [Replacement message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
|
- id: last
|
||||||
|
name: Last
|
||||||
|
messages: [Last message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: upsert
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
name: Shared replacement
|
||||||
|
messages: [Replacement message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET
|
||||||
|
- id: extra
|
||||||
|
name: Extra
|
||||||
|
messages: [Extra message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: append
|
||||||
|
prompts:
|
||||||
|
- id: foundation
|
||||||
|
name: Foundation
|
||||||
|
description: Shared foundation instruction
|
||||||
|
messages: [Foundation message]
|
||||||
|
resource: foundation.txt
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
|
- id: shared
|
||||||
|
name: Shared draft
|
||||||
|
description: This field must not survive a whole-record replacement
|
||||||
|
messages: [Draft message]
|
||||||
|
resource: shared.txt
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
|
|||||||
func TestFacadeCompatibility(t *testing.T) {
|
func TestFacadeCompatibility(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
|
||||||
for _, candidate := range []struct {
|
|
||||||
name string
|
|
||||||
type_ reflect.Type
|
|
||||||
}{
|
|
||||||
{"Path", reflect.TypeFor[configkit.Path]()},
|
|
||||||
{"Source", reflect.TypeFor[configkit.Source]()},
|
|
||||||
{"Secret", reflect.TypeFor[configkit.Secret]()},
|
|
||||||
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
|
|
||||||
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
|
|
||||||
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
|
|
||||||
} {
|
|
||||||
if candidate.type_.PkgPath() != packagePath {
|
|
||||||
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructed := configkit.Options[facadeCompatibilityConfig]{
|
constructed := configkit.Options[facadeCompatibilityConfig]{
|
||||||
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
||||||
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
||||||
@@ -56,11 +38,6 @@ func TestFacadeCompatibility(t *testing.T) {
|
|||||||
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
||||||
t.Fatalf("constructed override path = %q, want /value", got)
|
t.Fatalf("constructed override path = %q, want /value", got)
|
||||||
}
|
}
|
||||||
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
|
|
||||||
if publicResult.Config.Value != "constructed" {
|
|
||||||
t.Fatal("constructed result lost its public configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
||||||
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
||||||
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
||||||
|
|||||||
316
facade_discovery_test.go
Normal file
316
facade_discovery_test.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type facadeDiscoveryConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiscoveryLifecycleConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Resolved string `yaml:"resolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiscoveryPlacementConfig struct {
|
||||||
|
Items []facadeDiscoveryPlacementItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiscoveryPlacementItem struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chdir(original); err != nil {
|
||||||
|
t.Errorf("restore working directory: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
writeFacadeImportFile(t, root, "myapp.yml", "value: working\n")
|
||||||
|
|
||||||
|
result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory()))
|
||||||
|
if err != nil || result.Config.Value != "working" {
|
||||||
|
t.Fatalf("working-directory search = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) {
|
||||||
|
t.Fatalf("working-directory sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n")
|
||||||
|
custom := SearchDirectory(root, DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames("config.yml")},
|
||||||
|
})
|
||||||
|
result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom))
|
||||||
|
if err != nil || result.Config.Value != "found" {
|
||||||
|
t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) {
|
||||||
|
t.Fatalf("custom generic-only sources = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} }
|
||||||
|
empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||||
|
absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{
|
||||||
|
Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults},
|
||||||
|
})
|
||||||
|
if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 {
|
||||||
|
t.Fatalf("explicit empty filename selection = %#v, %v", absent, err)
|
||||||
|
}
|
||||||
|
omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root)))
|
||||||
|
if err != nil || omitted.Config.Value != "found" {
|
||||||
|
t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n")
|
||||||
|
laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
paths := []string{filepath.Join(root, "missing.yml")}
|
||||||
|
names := []string{"config.yml"}
|
||||||
|
groups := []FilenameGroup{CustomFilenames(names...)}
|
||||||
|
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
||||||
|
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
|
||||||
|
source := SearchConfig("", search, SearchOptions{Required: true})
|
||||||
|
|
||||||
|
paths[0] = laterPath
|
||||||
|
names[0] = "later.yml"
|
||||||
|
groups[0] = CustomFilenames("later.yml")
|
||||||
|
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
||||||
|
search = Paths(laterPath)
|
||||||
|
|
||||||
|
result, err := Load[facadeDiscoveryConfig](source)
|
||||||
|
if err != nil || result.Config.Value != "selected" {
|
||||||
|
t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) {
|
||||||
|
t.Fatalf("ordered search sources = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
foundMissing := false
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing
|
||||||
|
}
|
||||||
|
if !foundMissing {
|
||||||
|
t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||||
|
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
||||||
|
result, err := LoadLayered(
|
||||||
|
Input{Source: SearchConfig("", Paths(mainPath))},
|
||||||
|
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
||||||
|
})),
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) {
|
||||||
|
t.Fatalf("layered search = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
|
||||||
|
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
||||||
|
t.Fatalf("layered search sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n")
|
||||||
|
writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n")
|
||||||
|
filesystem := &FilesystemContext{Root: root}
|
||||||
|
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
||||||
|
|
||||||
|
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
|
||||||
|
if err != nil || selected.Config.Value != "overlay" {
|
||||||
|
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
sources := selected.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" ||
|
||||||
|
sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay {
|
||||||
|
t.Fatalf("selected search source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
mainContext, ok := selected.SourceContext("source-1")
|
||||||
|
if !ok || mainContext.BaseDir != "." {
|
||||||
|
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
|
||||||
|
searchedAbsent, err := Load(missing, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
||||||
|
}
|
||||||
|
explicitAbsent, err := Load(NoSource(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NoSource with root-only overlay context: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) ||
|
||||||
|
!reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) {
|
||||||
|
t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent)
|
||||||
|
}
|
||||||
|
left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value"))
|
||||||
|
right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value"))
|
||||||
|
if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK {
|
||||||
|
t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK)
|
||||||
|
}
|
||||||
|
if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay {
|
||||||
|
t.Fatalf("absent search created unexpected source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
||||||
|
outsideRoot := t.TempDir()
|
||||||
|
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
||||||
|
search := SearchConfig("", Paths(outside, inside))
|
||||||
|
|
||||||
|
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) {
|
||||||
|
t.Fatalf("selected outside-root file error = %v, want containment failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}})
|
||||||
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||||
|
t.Fatalf("selected file with explicit base error = %v, want option failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
native := NewFilesystem("native", fstest.MapFS{})
|
||||||
|
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}})
|
||||||
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||||
|
t.Fatalf("selected file with native context error = %v, want option failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||||
|
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
||||||
|
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||||
|
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
||||||
|
Defaults: func() facadeDiscoveryLifecycleConfig {
|
||||||
|
defaultsCalls++
|
||||||
|
return facadeDiscoveryLifecycleConfig{}
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
||||||
|
resolveCalls++
|
||||||
|
configuration.Resolved = "from:" + configuration.Value
|
||||||
|
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" {
|
||||||
|
t.Fatalf("search lifecycle result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 {
|
||||||
|
t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" {
|
||||||
|
t.Fatalf("selected source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
valueInfo, found := result.Metadata().Lookup(Keys("value"))
|
||||||
|
if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID {
|
||||||
|
t.Fatalf("selected value metadata = %#v, %v", valueInfo, found)
|
||||||
|
}
|
||||||
|
var rendered strings.Builder
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") {
|
||||||
|
t.Fatalf("effective output = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n")
|
||||||
|
options := Options[facadeDiscoveryPlacementConfig]{
|
||||||
|
Destination: Keys("items"),
|
||||||
|
PlacementMode: PlacementModeSequenceItem,
|
||||||
|
}
|
||||||
|
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
|
||||||
|
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
||||||
|
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem {
|
||||||
|
t.Fatalf("selected sequence-item source = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := func() facadeDiscoveryPlacementConfig {
|
||||||
|
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
||||||
|
}
|
||||||
|
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||||
|
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
||||||
|
Destination: options.Destination,
|
||||||
|
PlacementMode: options.PlacementMode,
|
||||||
|
})
|
||||||
|
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
|
||||||
|
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
|
||||||
|
for _, diagnostic := range err.Diagnostics() {
|
||||||
|
if diagnostic.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFacadePath(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
canonical, err := filepath.EvalSymlinks(absolute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ type equivalentConfig struct {
|
|||||||
|
|
||||||
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||||
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
||||||
|
bytesData := []byte(yaml)
|
||||||
|
bytesSource := configkit.Bytes("memory", bytesData)
|
||||||
|
bytesData[0] = 'X'
|
||||||
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
||||||
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
||||||
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
||||||
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes},
|
{"bytes", bytesSource, context, configkit.SourceKindBytes},
|
||||||
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
||||||
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
||||||
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
||||||
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
|||||||
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||||
t.Fatalf("reader re-consumption = %s", got)
|
t.Fatalf("reader re-consumption = %s", got)
|
||||||
}
|
}
|
||||||
|
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
|
||||||
|
readerCopy := copiedReader
|
||||||
|
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
|
||||||
|
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||||
|
t.Fatalf("copied reader re-consumption = %s", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
||||||
|
|||||||
249
facade_final_sequence_identity_test.go
Normal file
249
facade_final_sequence_identity_test.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
||||||
|
for _, phase := range []string{"defaults", "resolution"} {
|
||||||
|
for _, component := range []any{"application-data", nil} {
|
||||||
|
for _, duplicate := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%v/duplicate=%t", phase, component, duplicate), func(t *testing.T) {
|
||||||
|
items := []any{map[string]any{"extends": component}}
|
||||||
|
if duplicate {
|
||||||
|
items = append(items, map[string]any{"extends": component})
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "extends")},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
if phase == "defaults" {
|
||||||
|
return map[string]any{"items": items}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if phase == "resolution" {
|
||||||
|
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*value)["items"] = items
|
||||||
|
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if duplicate {
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
if loaded != nil || len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != "/items" {
|
||||||
|
t.Fatalf("duplicate effective payload diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("ordinary effective identity was interpreted as a directive: %v", err)
|
||||||
|
}
|
||||||
|
value := loaded.Config["items"].([]any)[0].(map[string]any)
|
||||||
|
if actual, present := value["extends"]; !present || actual != component {
|
||||||
|
t.Fatalf("effective payload = %#v", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
|
||||||
|
identity := configkit.SequenceIdentityAt(configkit.Keys("items"), "id")
|
||||||
|
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||||
|
for _, shape := range []string{"duplicate", "missing", "non-scalar"} {
|
||||||
|
t.Run(source+"/"+shape, func(t *testing.T) {
|
||||||
|
items := finalIdentityRecords(shape)
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{identity},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
if source == "defaults" {
|
||||||
|
return map[string]any{"items": items}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
options := configkit.Options[map[string]any]{Definition: definition}
|
||||||
|
switch source {
|
||||||
|
case "CLI":
|
||||||
|
options.Overrides = []configkit.Override{{Path: configkit.Keys("items"), Value: items, Flag: "--items"}}
|
||||||
|
case "resolution":
|
||||||
|
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*value)["items"] = items
|
||||||
|
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.NoSource(), options)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("invalid final identities returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
diagnostic := loadErr.Diagnostics()[0]
|
||||||
|
wantCode, wantPath := configkit.CodeSequenceInvalid, "/items/0/id"
|
||||||
|
if shape == "duplicate" {
|
||||||
|
wantCode, wantPath = configkit.CodeSequenceConflict, "/items"
|
||||||
|
}
|
||||||
|
if diagnostic.Code != wantCode || diagnostic.Path.String() != wantPath {
|
||||||
|
t.Fatalf("diagnostic = %#v, want %s at %s", diagnostic, wantCode, wantPath)
|
||||||
|
}
|
||||||
|
if source == "CLI" && (diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1") {
|
||||||
|
t.Fatalf("CLI identity diagnostic lost its source: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
|
||||||
|
selector := configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("items"),
|
||||||
|
}
|
||||||
|
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||||
|
t.Run(source, func(t *testing.T) {
|
||||||
|
name := map[string]string{"defaults": "team", "CLI": "cloud", "resolution": "orbit"}[source]
|
||||||
|
path := configkit.Keys("collections", name, "items")
|
||||||
|
items := finalIdentityRecords("duplicate")
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(selector, "id"),
|
||||||
|
},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
if source == "defaults" {
|
||||||
|
return map[string]any{"collections": map[string]any{name: map[string]any{"items": items}}}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
options := configkit.Options[map[string]any]{Definition: definition}
|
||||||
|
switch source {
|
||||||
|
case "CLI":
|
||||||
|
options.Overrides = []configkit.Override{{Path: path, Value: items, Flag: "--items"}}
|
||||||
|
case "resolution":
|
||||||
|
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
||||||
|
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.NoSource(), options)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("invalid dynamic identity returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != path.String() {
|
||||||
|
t.Fatalf("dynamic identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityAppliesToLayeredLoads(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
return map[string]any{"items": finalIdentityRecords("duplicate")}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("invalid layered final identities returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||||
|
t.Fatalf("layered identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityUsesTypedNumbersAndIgnoresUnmatchedShapes(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("unmatched"), "id"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: definition.SequenceIdentities,
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
return map[string]any{"items": []any{
|
||||||
|
map[string]any{"id": int64(1)}, map[string]any{"id": float64(1)},
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||||
|
t.Fatalf("typed numeric identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := configkit.Load(configkit.Mapping("valid", map[string]any{
|
||||||
|
"items": []any{
|
||||||
|
map[string]any{"id": nil}, map[string]any{"id": true},
|
||||||
|
map[string]any{"id": int64(1)}, map[string]any{"id": "one"},
|
||||||
|
},
|
||||||
|
"other": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||||
|
}), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if err != nil || valid == nil {
|
||||||
|
t.Fatalf("valid scalar identities and unmatched duplicate sequence = %#v, %v", valid, err)
|
||||||
|
}
|
||||||
|
if _, err := configkit.Load(configkit.Mapping("other-shapes", map[string]any{"items": nil}), configkit.Options[map[string]any]{Definition: definition}); err != nil {
|
||||||
|
t.Fatalf("null at a registered path was validated as an identity sequence: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityDiagnosticDoesNotRevealResolvedSecrets(t *testing.T) {
|
||||||
|
const canary = "FINAL-IDENTITY-CREDENTIAL-CANARY"
|
||||||
|
const secretName = "CONFIGKIT_FINAL_IDENTITY_SECRET"
|
||||||
|
t.Setenv(secretName, canary)
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Token configkit.Secret `yaml:"token"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Defaults: func() config {
|
||||||
|
return config{Items: []item{
|
||||||
|
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||||
|
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.NoSource(), configkit.Options[config]{Definition: definition})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("duplicate identity returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
if err == nil || strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("identity diagnostic leaked a resolved secret: %v", err)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||||
|
t.Fatalf("secret-bearing identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalIdentityRecords(shape string) []any {
|
||||||
|
switch shape {
|
||||||
|
case "duplicate":
|
||||||
|
return []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}}
|
||||||
|
case "missing":
|
||||||
|
return []any{map[string]any{"other": "value"}}
|
||||||
|
default:
|
||||||
|
return []any{map[string]any{"id": map[string]any{"nested": "value"}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
facade_historical_paths_test.go
Normal file
62
facade_historical_paths_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
|
||||||
|
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte(
|
||||||
|
"retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n",
|
||||||
|
)), configkit.Options[map[string]any]{Overrides: []configkit.Override{
|
||||||
|
{Path: configkit.Keys("retired"), Value: nil, Flag: "--retired"},
|
||||||
|
{Path: configkit.Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := loaded.Metadata()
|
||||||
|
paths := metadata.HistoricalPaths()
|
||||||
|
got := make([]string, len(paths))
|
||||||
|
for index, path := range paths {
|
||||||
|
got[index] = path.String()
|
||||||
|
if len(metadata.History(path)) == 0 {
|
||||||
|
t.Errorf("enumerated path %s has no history", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("historical paths = %v, want sorted unique pointers %v", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||||
|
t.Fatal("removed descendant remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||||
|
t.Fatal("enumerated numeric path did not follow the effective sequence shape")
|
||||||
|
}
|
||||||
|
if len(metadata.History(paths[6])) == 0 {
|
||||||
|
t.Fatal("enumerated numeric path lost history")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
|
||||||
|
t.Fatal("mapping-key path resolved against the effective sequence")
|
||||||
|
}
|
||||||
|
if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
|
||||||
|
t.Fatal("zero metadata contains historical paths")
|
||||||
|
}
|
||||||
|
|
||||||
|
paths[1] = configkit.Keys("mutated")
|
||||||
|
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
|
||||||
|
t.Fatalf("returned path slice mutated retained metadata: %v", pathStrings(again))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathStrings(paths []configkit.Path) []string {
|
||||||
|
result := make([]string, len(paths))
|
||||||
|
for index, path := range paths {
|
||||||
|
result[index] = path.String()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
99
facade_import_alias_test.go
Normal file
99
facade_import_alias_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
|
||||||
|
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
||||||
|
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
||||||
|
"data/resource.txt": "target resource",
|
||||||
|
"aliases/resource.txt": "alias resource",
|
||||||
|
} {
|
||||||
|
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, target := range map[string]string{"aliases/item.yml": "../data/item.yml", "linked": "data"} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("aliases", os.DirFS(root))
|
||||||
|
type configuration struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Owner string `yaml:"owner"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, declaration, item, helper, owner, resource string
|
||||||
|
}{
|
||||||
|
{"file/path", "path: aliases/item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||||
|
{"file/dir", "dir: aliases\n pattern: item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||||
|
{"file/glob", "glob: 'aliases/ite*.yml'", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||||
|
{"directory/path", "path: linked/item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||||
|
{"directory/dir", "dir: linked\n pattern: item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||||
|
{"directory/glob", "glob: 'lin*/ite*.yml'", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Owner != test.owner || result.Config.Value != "item" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[1].Path != test.item || sources[2].Path != test.helper {
|
||||||
|
t.Fatalf("source identities = %#v", sources)
|
||||||
|
}
|
||||||
|
for field, source := range map[string]configkit.SourceInfo{"value": sources[1], "resource": sources[2]} {
|
||||||
|
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != source.ID {
|
||||||
|
t.Fatalf("%s origin = %#v", field, info)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(source.ID)
|
||||||
|
if !ok || context.BaseDir != path.Dir(source.Path) {
|
||||||
|
t.Fatalf("%s context = %#v", field, context)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Resource))
|
||||||
|
if err != nil || string(contents) != test.resource {
|
||||||
|
t.Fatalf("%s resource = %q, %v", field, contents, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The same native path is still a repeat when separately included. An alias
|
||||||
|
// to a leaf file remains a different native namespace path, as for path imports.
|
||||||
|
for _, selector := range []string{"path: aliases/item.yml", "dir: aliases\n pattern: item.yml", "glob: 'aliases/ite*.yml'"} {
|
||||||
|
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
||||||
|
t.Fatalf("repeated source: %v", err)
|
||||||
|
}
|
||||||
|
_, err = configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
|
})
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
|
t.Fatalf("distinct native alias paths should reach composition: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
299
facade_import_defaults_test.go
Normal file
299
facade_import_defaults_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
depth string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"999999999999999999999999.5", false},
|
||||||
|
{"999999999999999999999999e1", false},
|
||||||
|
{"999999999999999999999999", true},
|
||||||
|
{"-0", true},
|
||||||
|
} {
|
||||||
|
t.Run(test.depth, func(t *testing.T) {
|
||||||
|
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfigkit:\n imports:\n - dir: absent\n recursive: true\n depth: "+test.depth+"\n")
|
||||||
|
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
|
||||||
|
if test.valid {
|
||||||
|
if err != nil || result.Config["value"] != true {
|
||||||
|
t.Fatalf("valid depth: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("invalid depth accepted: %v", err)
|
||||||
|
}
|
||||||
|
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
|
||||||
|
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Main string `yaml:"main"`
|
||||||
|
Transitive string `yaml:"transitive"`
|
||||||
|
Exception string `yaml:"exception"`
|
||||||
|
Underlay string `yaml:"underlay"`
|
||||||
|
IgnoredDeep string `yaml:"ignored_deep"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: parent/parent.yml
|
||||||
|
main: main
|
||||||
|
`)
|
||||||
|
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: generated
|
||||||
|
- dir: explicit
|
||||||
|
pattern: '*.yaml'
|
||||||
|
recursive: false
|
||||||
|
`)
|
||||||
|
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
|
||||||
|
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
|
||||||
|
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
|
||||||
|
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
|
||||||
|
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
||||||
|
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: underlay-files
|
||||||
|
`)
|
||||||
|
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
|
||||||
|
recursive := true
|
||||||
|
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||||
|
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
|
||||||
|
t.Fatalf("configuration = %#v", loaded.Config)
|
||||||
|
}
|
||||||
|
paths := make(map[string]bool)
|
||||||
|
for _, source := range loaded.Sources() {
|
||||||
|
paths[source.Path] = true
|
||||||
|
}
|
||||||
|
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
|
||||||
|
if !paths[canonicalFacadeImportPath(t, selected)] {
|
||||||
|
t.Errorf("selected source %q missing from source records", selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: snippets
|
||||||
|
`)
|
||||||
|
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
||||||
|
|
||||||
|
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
||||||
|
if err != nil || unrestricted.Config.Value != "imported" {
|
||||||
|
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
||||||
|
}
|
||||||
|
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
||||||
|
if err != nil || empty.Config.Value != "" {
|
||||||
|
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||||
|
}
|
||||||
|
emptySources := empty.Sources()
|
||||||
|
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone {
|
||||||
|
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||||
|
}
|
||||||
|
required := true
|
||||||
|
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
||||||
|
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Imported string `yaml:"imported"`
|
||||||
|
}
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- dir: parts
|
||||||
|
`)
|
||||||
|
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
|
||||||
|
|
||||||
|
recursive := true
|
||||||
|
patterns := []string{"*.cfg"}
|
||||||
|
definition := Definition[config]{Version: &VersionRule{
|
||||||
|
Path: Keys("version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
if version != (Version{Present: true, Text: "v1"}) {
|
||||||
|
t.Errorf("version callback received %#v", version)
|
||||||
|
}
|
||||||
|
recursive = false
|
||||||
|
patterns[0] = "*.skip"
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
load := func() (*Result[config], error) {
|
||||||
|
defaults := ImportDefaults{Recursive: &recursive, Pattern: patterns}
|
||||||
|
if layered {
|
||||||
|
return LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||||
|
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, ImportDefaults: defaults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Load(File(mainPath), Options[config]{
|
||||||
|
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, Imports: true, ImportDefaults: defaults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := load()
|
||||||
|
if err != nil || first.Config.Imported != "selected" {
|
||||||
|
t.Fatalf("load with pre-callback defaults = %#v, %v", first, err)
|
||||||
|
}
|
||||||
|
if recursive || patterns[0] != "*.skip" {
|
||||||
|
t.Fatalf("version callback did not mutate caller defaults: recursive=%v pattern=%q", recursive, patterns[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := load()
|
||||||
|
if err != nil || second.Config.Imported != "" {
|
||||||
|
t.Fatalf("subsequent load reused earlier defaults: %#v, %v", second, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
input := strings.NewReader("value: read\n")
|
||||||
|
underlayInput := strings.NewReader("value: underlay\n")
|
||||||
|
defaultsCalled := false
|
||||||
|
resolveCalled := false
|
||||||
|
definition := Definition[config]{
|
||||||
|
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||||
|
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
|
||||||
|
}
|
||||||
|
defaults := ImportDefaults{Pattern: []string{"["}}
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
|
||||||
|
Definition: definition, ImportDefaults: defaults,
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
if loadError != nil {
|
||||||
|
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("layered=%v error = %T %v", layered, err, err)
|
||||||
|
}
|
||||||
|
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
|
||||||
|
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: absent
|
||||||
|
value: loaded
|
||||||
|
`)
|
||||||
|
callbackPath := ""
|
||||||
|
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
first, ok := resolution.Source("source-1")
|
||||||
|
if !ok || len(first.ImportSelections) != 1 {
|
||||||
|
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||||
|
}
|
||||||
|
first.ImportSelections[0].Path = Keys("callback mutation")
|
||||||
|
second, ok := resolution.Source("source-1")
|
||||||
|
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||||
|
}
|
||||||
|
callbackPath = second.ImportSelections[0].Path.String()
|
||||||
|
return nil
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if callbackPath != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("callback path = %q", callbackPath)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory {
|
||||||
|
t.Fatalf("result source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
sources[0].ImportSelections[0].Path = Keys("result mutation")
|
||||||
|
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: absent
|
||||||
|
- path: required-missing.yml
|
||||||
|
required: true
|
||||||
|
`)
|
||||||
|
_, err = Load(File(failurePath), Options[config]{Imports: true})
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("failed load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
errorSources := loadError.Sources()
|
||||||
|
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("error source evidence = %#v", errorSources)
|
||||||
|
}
|
||||||
|
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
|
||||||
|
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
filename := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFacadeImportPath(t *testing.T, filename string) string {
|
||||||
|
t.Helper()
|
||||||
|
canonical, err := filepath.EvalSymlinks(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(canonical)
|
||||||
|
}
|
||||||
135
facade_import_lifecycle_test.go
Normal file
135
facade_import_lifecycle_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
||||||
|
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"bundle/config/main.yml": {Data: []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- dir: records
|
||||||
|
pattern: '*-item.yml'
|
||||||
|
destination: [services, records]
|
||||||
|
placement: sequence-item
|
||||||
|
services:
|
||||||
|
records:
|
||||||
|
- id: authored
|
||||||
|
version: authored
|
||||||
|
`)},
|
||||||
|
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- path: helper.yml
|
||||||
|
version: ` + canary + `
|
||||||
|
entries:
|
||||||
|
parent:
|
||||||
|
resource: icon.txt
|
||||||
|
child:
|
||||||
|
extends: parent
|
||||||
|
`)},
|
||||||
|
"bundle/config/records/helper.yml": {Data: []byte("id: helper-supplied\n")},
|
||||||
|
"bundle/config/records/icon.txt": {Data: []byte("icon payload")},
|
||||||
|
})
|
||||||
|
result, err := Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||||
|
Definition: Definition[map[string]any]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||||
|
Secret: func(candidate Path) bool {
|
||||||
|
return candidate.String() == "/services/records/0/version"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SourceVersionMode: SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var loadError *LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load discovered item: %v; diagnostics: %#v", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("load discovered item: %v", err)
|
||||||
|
}
|
||||||
|
services := result.Config["services"].(map[string]any)
|
||||||
|
records := services["records"].([]any)
|
||||||
|
if len(records) != 2 || records[0].(map[string]any)["id"] != "helper-supplied" || records[0].(map[string]any)["version"] != nil || records[1].(map[string]any)["version"] != "authored" {
|
||||||
|
t.Fatalf("nested records = %#v", records)
|
||||||
|
}
|
||||||
|
childPath := Keys("services", "records").Index(0).Key("entries").Key("child").Key("resource")
|
||||||
|
child := records[0].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||||
|
if child["resource"] != "icon.txt" {
|
||||||
|
t.Fatalf("inherited relative resource = %#v", child)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := result.Sources()
|
||||||
|
filesByID := make(map[string]string, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
filesByID[source.ID] = source.Path
|
||||||
|
}
|
||||||
|
var itemSource SourceInfo
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == "bundle/config/records/a-item.yml" {
|
||||||
|
itemSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
|
||||||
|
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
|
||||||
|
}
|
||||||
|
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
||||||
|
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||||
|
t.Fatalf("assigned helper ID origin = %#v", idInfo)
|
||||||
|
}
|
||||||
|
resourceInfo, ok := result.Metadata().Lookup(childPath)
|
||||||
|
if !ok || len(resourceInfo.Origin.Locations) != 1 || filesByID[resourceInfo.Origin.Locations[0].SourceID] != "bundle/config/records/a-item.yml" {
|
||||||
|
t.Fatalf("inherited resource metadata = %#v", resourceInfo)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(childPath)
|
||||||
|
if !ok || explanation.Effective.YAML != "icon.txt\n" || len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != resourceInfo.Origin.Locations[0].SourceID {
|
||||||
|
t.Fatalf("inherited resource explanation = %#v", explanation)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.FS == nil || context.BaseDir != "config/records" {
|
||||||
|
t.Fatalf("inherited resource context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child["resource"].(string)))
|
||||||
|
if err != nil || string(contents) != "icon payload" {
|
||||||
|
t.Fatalf("inherited resource read = %q, %v", contents, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||||
|
Definition: Definition[map[string]any]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||||
|
Secret: func(candidate Path) bool {
|
||||||
|
return candidate.String() == "/services/records/0/version"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SourceVersionMode: SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid {
|
||||||
|
t.Fatalf("consumed header supplied an identity: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("diagnostic exposed the consumed secret header: %v", err)
|
||||||
|
}
|
||||||
|
var failedItemSource SourceInfo
|
||||||
|
foundFailedItem := false
|
||||||
|
for _, source := range loadError.Sources() {
|
||||||
|
if source.Path == "bundle/config/records/a-item.yml" {
|
||||||
|
failedItemSource = source
|
||||||
|
foundFailedItem = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
|
||||||
|
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
facade_import_root_test.go
Normal file
119
facade_import_root_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
||||||
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parts := filepath.Join(root, "real", "parts")
|
||||||
|
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
partPath := filepath.Join(parts, "part.yml")
|
||||||
|
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
for _, follow := range []bool{true, false} {
|
||||||
|
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
||||||
|
mainPath := filepath.Join(root, "main.yml")
|
||||||
|
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
||||||
|
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != "included" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
|
||||||
|
t.Fatalf("canonical imported source = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
parts := filepath.Join(root, "nested/root/parts")
|
||||||
|
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(parts, "value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("parts", filepath.Join(root, "nested/root/linked")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("nested", os.DirFS(root))
|
||||||
|
context := &configkit.FilesystemContext{FS: &filesystem, Root: "nested/root", BaseDir: "nested/root"}
|
||||||
|
for _, selector := range []string{
|
||||||
|
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
||||||
|
} {
|
||||||
|
t.Run(selector, func(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != int64(1) {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "nested/outside.yml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("../../outside.yml", filepath.Join(parts, "escape.yml")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
|
||||||
|
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
||||||
|
t.Fatalf("escaping %s: %v", selector, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
|
||||||
|
for _, native := range []bool{false, true} {
|
||||||
|
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
|
||||||
|
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||||
|
if native {
|
||||||
|
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != int64(1) {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
SequenceIdentities: 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]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil || strings.Contains(err.Error(), canary) {
|
||||||
|
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")},
|
||||||
@@ -206,27 +414,24 @@ func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
|
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
||||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
||||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
||||||
})
|
})
|
||||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
||||||
})
|
})
|
||||||
if err != nil || result.Config.Entries["child"].Count != 2 {
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
t.Fatalf("import and CLI reference = %#v, %v", result, err)
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOverrideDirective {
|
||||||
}
|
t.Fatalf("CLI inheritance relationship change = %v", err)
|
||||||
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
|
||||||
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
|
||||||
t.Fatalf("superseded reference history = %#v", history)
|
|
||||||
}
|
}
|
||||||
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
||||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
|
||||||
})
|
})
|
||||||
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
||||||
loadError, ok := err.(*configkit.LoadError)
|
loadError, ok = err.(*configkit.LoadError)
|
||||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
t.Fatalf("strict conflict before expansion = %v", err)
|
t.Fatalf("strict conflict before expansion = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
Value string `yaml:"value"`
|
|
||||||
}
|
|
||||||
data := []byte("value: retained\n")
|
|
||||||
source := Bytes("snapshot.yml", data)
|
|
||||||
data[7] = 'X'
|
|
||||||
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
|
|
||||||
if err != nil || result.Config.Value != "retained" {
|
|
||||||
t.Fatalf("snapshotted source = %#v, %v", result, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
|
|
||||||
first := readerSource
|
|
||||||
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
|
|
||||||
var loadError *engine.LoadError
|
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
|
|
||||||
t.Fatalf("copied reader source error = %#v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parsed, err := ParsePath("/0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
keyed, indexed := Keys("0"), Path{}.Index(0)
|
|
||||||
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
|
|
||||||
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
|
|
||||||
}
|
|
||||||
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
|
|
||||||
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
69
facade_item_registration_scope_test.go
Normal file
69
facade_item_registration_scope_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
for _, dynamic := range []bool{false, true} {
|
||||||
|
for _, missingOuterID := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("layered=%t/dynamic=%t/missing-outer-id=%t", layered, dynamic, missingOuterID), func(t *testing.T) {
|
||||||
|
payload := "prompts: [{text: helper}]\n"
|
||||||
|
unused := configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")
|
||||||
|
if dynamic {
|
||||||
|
payload = "catalog: {local: {prompts: [{text: helper}]}}\n"
|
||||||
|
unused = configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id")
|
||||||
|
}
|
||||||
|
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
||||||
|
if !missingOuterID {
|
||||||
|
item += "id: outer\n"
|
||||||
|
}
|
||||||
|
files := configkit.NewFilesystem("items", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
|
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
||||||
|
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||||
|
"child-helper.yml": {Data: []byte(payload)},
|
||||||
|
})
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
unused, configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||||
|
}}
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: files.File("main.yml"), Imports: true}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||||
|
} else {
|
||||||
|
result, err = configkit.Load(files.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||||
|
}
|
||||||
|
if missingOuterID {
|
||||||
|
failure := requireFacadeLoadError(t, err)
|
||||||
|
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeComposeInvalid || failure.Diagnostics()[0].Path.String() != "/items/0/id" {
|
||||||
|
t.Fatalf("outer constraint diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, record := range []configkit.Path{configkit.Keys("items").Index(0), configkit.Keys("items").Index(0).Key("children").Index(0)} {
|
||||||
|
path := record
|
||||||
|
if dynamic {
|
||||||
|
path = path.Key("catalog").Key("local")
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(path.Key("prompts").Index(0).Key("text"))
|
||||||
|
if !found || !info.Explicit {
|
||||||
|
t.Fatalf("nested helper payload lost at %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
facade_item_version_test.go
Normal file
64
facade_item_version_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, item, child, helper string
|
||||||
|
underlay, mainItem bool
|
||||||
|
}{
|
||||||
|
{name: "item", item: "id: item\nversion: domain\n"},
|
||||||
|
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
|
||||||
|
{name: "nested", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n"},
|
||||||
|
{name: "underlay", item: "id: item\nversion: domain\n", underlay: true},
|
||||||
|
{name: "main-item", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n", mainItem: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
|
"item.yml": {Data: []byte(test.item)},
|
||||||
|
"child.yml": {Data: []byte(test.child)},
|
||||||
|
"helper.yml": {Data: []byte(test.helper)},
|
||||||
|
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("items", files)
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}}
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case test.underlay:
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"version": "1"})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: filesystem.File("underlay.yml"), Imports: true}}}},
|
||||||
|
})
|
||||||
|
case test.mainItem:
|
||||||
|
result, err = configkit.Load(filesystem.File("item.yml"), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, Definition: definition,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item := result.Config["items"].([]any)[0].(map[string]any)
|
||||||
|
fieldPath := configkit.Keys("items").Index(0).Key("version")
|
||||||
|
if test.child != "" {
|
||||||
|
item = item["children"].([]any)[0].(map[string]any)
|
||||||
|
fieldPath = configkit.Keys("items").Index(0).Key("children").Index(0).Key("version")
|
||||||
|
}
|
||||||
|
if item["version"] != "domain" {
|
||||||
|
t.Fatalf("domain version = %#v", item)
|
||||||
|
}
|
||||||
|
if info, ok := result.Metadata().Lookup(fieldPath); !ok || !info.Explicit {
|
||||||
|
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
240
facade_sequence_identity_test.go
Normal file
240
facade_sequence_identity_test.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {
|
||||||
|
fields := []string{"namespace", "id", "id"}
|
||||||
|
selector := configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}
|
||||||
|
identity := configkit.SequenceIdentityMatching(selector, fields...)
|
||||||
|
selector[1] = configkit.SequenceLiteralKey("other")
|
||||||
|
fields[0] = "changed"
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}}
|
||||||
|
|
||||||
|
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}},
|
||||||
|
}})
|
||||||
|
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}},
|
||||||
|
}})
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load after mutating constructor inputs: %v", err)
|
||||||
|
}
|
||||||
|
prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 {
|
||||||
|
t.Fatalf("registered compound identity did not append records: %#v", prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) {
|
||||||
|
dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id")
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"),
|
||||||
|
dynamic,
|
||||||
|
}}
|
||||||
|
underlay := configkit.Mapping("underlay", map[string]any{
|
||||||
|
"prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}},
|
||||||
|
"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||||
|
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
main := configkit.Mapping("main", map[string]any{
|
||||||
|
"prompts": []any{map[string]any{"namespace": "beta", "id": "same"}},
|
||||||
|
"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||||
|
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load mixed exact and dynamic registrations: %v", err)
|
||||||
|
}
|
||||||
|
rootRecords := loaded.Config["prompts"].([]any)
|
||||||
|
if len(rootRecords) != 2 {
|
||||||
|
t.Fatalf("compound root identities = %#v", rootRecords)
|
||||||
|
}
|
||||||
|
collections := loaded.Config["collections"].(map[string]any)
|
||||||
|
for _, name := range []string{"team", "cloud"} {
|
||||||
|
prompts := collections[name].(map[string]any)["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 {
|
||||||
|
t.Errorf("dynamic prompts for %q = %#v", name, prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceLiteralKey("*"),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||||
|
"*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}},
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||||
|
}})
|
||||||
|
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||||
|
"*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}},
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||||
|
}})
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||||
|
})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||||
|
t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: wildcard,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wildcard registration did not match mapping keys: %v", err)
|
||||||
|
}
|
||||||
|
for name, raw := range loaded.Config["collections"].(map[string]any) {
|
||||||
|
if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 {
|
||||||
|
t.Errorf("wildcard result for %q has %d records", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
identity configkit.SequenceIdentity
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "index path",
|
||||||
|
identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"),
|
||||||
|
wantPath: "/records/0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty fields",
|
||||||
|
identity: configkit.SequenceIdentityAt(configkit.Keys("records")),
|
||||||
|
wantPath: "/records",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
reader := strings.NewReader("records: []\n")
|
||||||
|
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}},
|
||||||
|
})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath {
|
||||||
|
t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
if reader.Len() == 0 {
|
||||||
|
t.Fatal("source was read before registration validation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) {
|
||||||
|
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()}
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(selector, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"),
|
||||||
|
}, "slug"),
|
||||||
|
}}
|
||||||
|
reader := strings.NewReader("collections: {}\n")
|
||||||
|
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||||
|
t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
if reader.Len() == 0 {
|
||||||
|
t.Fatal("source was read before overlapping registrations were rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("entries"),
|
||||||
|
configkit.SequenceLiteralKey("child"),
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs:
|
||||||
|
entries:
|
||||||
|
parent:
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts: [{id: inherited, text: base}]
|
||||||
|
child:
|
||||||
|
extends: parent
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts: [{id: added, text: child}]
|
||||||
|
`)), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load with dynamic inherited destination: %v", err)
|
||||||
|
}
|
||||||
|
child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any)
|
||||||
|
prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 {
|
||||||
|
t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
for _, source := range []string{"first", "second"} {
|
||||||
|
loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{
|
||||||
|
source: []any{map[string]any{"id": "reused"}},
|
||||||
|
}}), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 {
|
||||||
|
t.Fatalf("independent load %q = %#v, %v", source, loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError {
|
||||||
|
t.Helper()
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
|||||||
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
||||||
}
|
}
|
||||||
if registered {
|
if registered {
|
||||||
definition.SequenceCompositions = []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
definition.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}
|
||||||
}
|
}
|
||||||
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,7 +51,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
||||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||||
@@ -63,24 +63,22 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(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)
|
||||||
}
|
}
|
||||||
sourceOrder := make(map[string]int)
|
|
||||||
sourceFiles := make(map[string]string)
|
sourceFiles := make(map[string]string)
|
||||||
for index, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
sourceOrder[source.ID] = index
|
|
||||||
sourceFiles[source.ID] = source.Path
|
sourceFiles[source.ID] = source.Path
|
||||||
}
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
path configkit.Path
|
path configkit.Path
|
||||||
file, text string
|
file, text string
|
||||||
}{
|
}{
|
||||||
{configkit.Keys("records").Index(1).Key("text"), "helper-one.yml", "first helper\n"},
|
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||||
{configkit.Keys("records").Index(2).Key("text"), "helper-two.yml", "second helper\n"},
|
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||||
{configkit.Keys("records").Index(1).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||||
} {
|
} {
|
||||||
explanation, ok := result.Explain(test.path)
|
explanation, ok := result.Explain(test.path)
|
||||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||||
@@ -95,20 +93,6 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
|||||||
t.Fatal("metadata and explanation disagree")
|
t.Fatal("metadata and explanation disagree")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
explanation, ok := result.Explain(configkit.Keys())
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("root explanation missing")
|
|
||||||
}
|
|
||||||
previous := -1
|
|
||||||
for _, change := range explanation.History {
|
|
||||||
for _, location := range change.Origin.Locations {
|
|
||||||
current := sourceOrder[location.SourceID]
|
|
||||||
if current < previous {
|
|
||||||
t.Fatalf("source %s replayed out of traversal order", location.SourceID)
|
|
||||||
}
|
|
||||||
previous = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||||
@@ -123,8 +107,14 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
|||||||
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"records": test.upper})}, configkit.LayeredOptions[map[string]any]{
|
mainYAML := "configkit:\n sequences:\n - destination: [records]\n key: id\n mode: replace\n"
|
||||||
Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
|
if len(test.upper) == 0 {
|
||||||
|
mainYAML += "records: []\n"
|
||||||
|
} else {
|
||||||
|
mainYAML += "records: [{id: main}]\n"
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{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 {
|
||||||
@@ -153,14 +143,14 @@ 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
|
||||||
}{
|
}{
|
||||||
{"preceding authored and imported items", "main.yml", "/records/2/id", false, false},
|
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||||
{"nested item", "nested.yml", "/records/1/children/1/id", false, false},
|
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||||
{"preceding grouped input", "group.yml", "/records/2/id", true, false},
|
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||||
} {
|
} {
|
||||||
@@ -193,8 +183,16 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
for _, source := range loadError.Sources() {
|
for _, source := range loadError.Sources() {
|
||||||
files[source.ID] = source.Path
|
files[source.ID] = source.Path
|
||||||
}
|
}
|
||||||
if diagnostic.Primary == nil || files[diagnostic.Primary.SourceID] != "bad.yml" || len(diagnostic.Related) != 1 || files[diagnostic.Related[0].SourceID] != "helper.yml" {
|
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||||
t.Fatal("conflict lost physical source locations")
|
if diagnostic.Primary != nil {
|
||||||
|
locations = append(locations, *diagnostic.Primary)
|
||||||
|
}
|
||||||
|
gotFiles := make(map[string]bool, len(locations))
|
||||||
|
for _, location := range locations {
|
||||||
|
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
|
||||||
|
}
|
||||||
|
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
|
||||||
|
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -202,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,
|
||||||
@@ -248,8 +246,8 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("registered sequence precedence load: %v", err)
|
t.Fatalf("registered sequence precedence load: %v", err)
|
||||||
}
|
}
|
||||||
if got := precedence.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "main" {
|
if got := precedence.Config["entries"].([]any); len(got) != 2 || got[0].(map[string]any)["id"] != "lower" || got[1].(map[string]any)["id"] != "main" {
|
||||||
t.Fatalf("higher-priority list did not replace the lower sequence: %#v", precedence.Config)
|
t.Fatalf("registered upsert did not retain the lower sequence: %#v", precedence.Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
@@ -303,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)
|
||||||
@@ -326,7 +324,7 @@ items:
|
|||||||
t.Fatalf("load item glob: %v", err)
|
t.Fatalf("load item glob: %v", err)
|
||||||
}
|
}
|
||||||
items := result.Config["items"].([]any)
|
items := result.Config["items"].([]any)
|
||||||
wantIDs := []string{"authored", "imported-a", "imported-b"}
|
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||||
for index, item := range items {
|
for index, item := range items {
|
||||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||||
@@ -344,10 +342,10 @@ items:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata := result.Metadata()
|
metadata := result.Metadata()
|
||||||
for index := 1; index < len(wantIDs); index++ {
|
for index := 0; index < 2; index++ {
|
||||||
path := configkit.Keys("items").Index(index).Key("id")
|
path := configkit.Keys("items").Index(index).Key("id")
|
||||||
info, ok := metadata.Lookup(path)
|
info, ok := metadata.Lookup(path)
|
||||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-1] {
|
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
|
||||||
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,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,6 +514,31 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("lower", []byte("entries: [{id: lower}]\n")),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index, id := range []string{"lower", "main"} {
|
||||||
|
path := configkit.Keys("entries").Index(index).Key("id")
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok || !explanation.Effective.Present || explanation.Effective.YAML != id+"\n" || len(explanation.History) == 0 {
|
||||||
|
t.Fatalf("tier history at %s = %#v, %v", path.String(), explanation, ok)
|
||||||
|
}
|
||||||
|
if explanation.History[len(explanation.History)-1].After.YAML != id+"\n" {
|
||||||
|
t.Fatalf("tier history at %s does not retain final record: %#v", path.String(), explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
|
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
|
||||||
type record struct {
|
type record struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
@@ -531,7 +556,12 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := filepath.Join(directory, "main.yml")
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
underlayPath := filepath.Join(directory, "underlay.yml")
|
underlayPath := filepath.Join(directory, "underlay.yml")
|
||||||
if err := os.WriteFile(mainPath, []byte(`entries:
|
if err := os.WriteFile(mainPath, []byte(`configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [entries]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
|
entries:
|
||||||
- id: current
|
- id: current
|
||||||
count: 0
|
count: 0
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -567,7 +597,7 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
|
|||||||
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"
|
||||||
},
|
},
|
||||||
|
|||||||
197
facade_sequence_version_test.go
Normal file
197
facade_sequence_version_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type movedVersionItem struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type movedVersionCatalog struct {
|
||||||
|
Items []movedVersionItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, mounted := range []bool{false, true} {
|
||||||
|
for _, matching := range []bool{false, true} {
|
||||||
|
for _, version := range []string{"4", "'4'"} {
|
||||||
|
t.Run(fmt.Sprintf("mounted=%t/matched=%t/%s", mounted, matching, version), func(t *testing.T) {
|
||||||
|
body := "items: [{id: main, version: " + version + "}]\n"
|
||||||
|
path := configkit.Keys("items").Index(0).Key("version")
|
||||||
|
main := configkit.Input{}
|
||||||
|
if mounted {
|
||||||
|
body, path = "id: main\nversion: "+version+"\n", configkit.Keys("version")
|
||||||
|
main.Destination, main.PlacementMode = configkit.Keys("items"), configkit.PlacementModeSequenceItem
|
||||||
|
}
|
||||||
|
main.Source = configkit.Bytes("main", []byte(body))
|
||||||
|
keep := "{id: keep}"
|
||||||
|
if mounted {
|
||||||
|
keep = "{id: keep, version: ordinary}"
|
||||||
|
}
|
||||||
|
lower := "items: [" + keep + "]\n"
|
||||||
|
if matching {
|
||||||
|
lower = "items: [" + keep + ", {id: main}]\n"
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
loaded, err := configkit.LoadLayered(main, configkit.LayeredOptions[movedVersionCatalog]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(lower))}}}},
|
||||||
|
Definition: configkit.Definition[movedVersionCatalog]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Version: &configkit.VersionRule{Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
calls++
|
||||||
|
if !version.Present || version.Text != "4" {
|
||||||
|
t.Errorf("checked version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 1 || len(loaded.Config.Items) != 2 || loaded.Config.Items[1].ID != "main" || loaded.Config.Items[1].Version != "4" {
|
||||||
|
t.Fatalf("moved main = %#v, checker calls = %d", loaded.Config, calls)
|
||||||
|
}
|
||||||
|
if mounted && loaded.Config.Items[0].Version != "ordinary" {
|
||||||
|
t.Fatal("unrelated underlay payload was treated as the main declaration")
|
||||||
|
}
|
||||||
|
for _, source := range loaded.Sources() {
|
||||||
|
if source.Layer == configkit.LayerBase && source.VersionCheck.Path.String() != path.String() {
|
||||||
|
t.Fatal("effective movement changed source-local version evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayVersionProtectionUsesAssignedRecordPositions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, payload string
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{name: "change main header", payload: "[{id: main, version: changed}]", code: configkit.CodeOverlayVersion},
|
||||||
|
{name: "remove main header", payload: "[{id: main}]", code: configkit.CodeOverlayVersion},
|
||||||
|
{name: "other record version", payload: "[{id: keep, version: ordinary}]"},
|
||||||
|
{name: "new record version", payload: "[{id: new, version: ordinary}]"},
|
||||||
|
{name: "only main header gets integer conversion", payload: "[{id: new, version: 5}]", code: configkit.CodeDecodeType},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
files := configkit.NewFilesystem("catalog", fstest.MapFS{
|
||||||
|
"overlays/test.yml": {Data: []byte("items: " + test.payload + "\n")},
|
||||||
|
})
|
||||||
|
calls := 0
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{FS: &files, BaseDir: ".", Root: "."},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte("items: [{id: keep}]\n"))}}}},
|
||||||
|
Definition: configkit.Definition[movedVersionCatalog]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("checker calls = %d", calls)
|
||||||
|
}
|
||||||
|
if test.code != "" {
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if loaded != nil || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != test.code {
|
||||||
|
t.Fatalf("version diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Config.Items[1].Version != "4" {
|
||||||
|
t.Fatal("unrelated overlay changed main header")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnderlayVersionProtectionUsesMainRecordPosition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, imported := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("imported=%t", imported), func(t *testing.T) {
|
||||||
|
payload := "items: [{id: keep}, {id: main, version: forbidden}]\n"
|
||||||
|
lower := configkit.Input{Source: configkit.Bytes("lower", []byte(payload))}
|
||||||
|
want := configkit.CodeUnderlayVersion
|
||||||
|
if imported {
|
||||||
|
files := configkit.NewFilesystem("lower", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||||
|
"fragment.yml": {Data: []byte(payload)},
|
||||||
|
})
|
||||||
|
lower.Source, lower.Imports = files.File("main.yml"), true
|
||||||
|
want = configkit.CodeImportVersion
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")),
|
||||||
|
Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{lower}}},
|
||||||
|
Definition: configkit.Definition[movedVersionCatalog]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
failure := requireFacadeLoadError(t, err)
|
||||||
|
if result != nil || calls != 1 || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != want || failure.Diagnostics()[0].Path.String() != "/items/1/version" {
|
||||||
|
t.Fatalf("result=%#v calls=%d diagnostics=%#v", result, calls, failure.Diagnostics())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainVersionFollowsInheritedSequenceMovement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, parentID := range []string{"keep", "main"} {
|
||||||
|
t.Run(parentID, func(t *testing.T) {
|
||||||
|
body := "base: {items: [{id: " + parentID + ", version: ordinary}]}\nchild:\n extends: base\n items: [{id: main, version: 4}]\n"
|
||||||
|
type config struct {
|
||||||
|
Base, Child movedVersionCatalog
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte(body)), configkit.Options[config]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Definition: configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("base", "items"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("child", "items"), "id"),
|
||||||
|
},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("child", "items").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("checker calls = %d", calls)
|
||||||
|
}
|
||||||
|
if parentID == "main" {
|
||||||
|
failure := requireFacadeLoadError(t, err)
|
||||||
|
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeInheritanceVersion {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Child.Items) != 2 || result.Config.Child.Items[1].Version != "4" || result.Config.Child.Items[0].Version != "ordinary" {
|
||||||
|
t.Fatalf("inherited items = %#v", result.Config.Child.Items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,17 @@ import (
|
|||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
|
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
for _, grouped := range []bool{false, true} {
|
for _, grouped := range []bool{false, true} {
|
||||||
for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} {
|
index := 0
|
||||||
|
if grouped {
|
||||||
|
index = 1
|
||||||
|
}
|
||||||
|
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 },
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,16 @@ func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if !grouped {
|
||||||
|
records, ok := result.Config["records"].([]any)
|
||||||
|
if !ok || len(records) != 2 {
|
||||||
|
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||||
|
}
|
||||||
|
firstRecord, ok := records[0].(map[string]any)
|
||||||
|
if !ok || firstRecord["id"] != "child" {
|
||||||
|
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||||
|
}
|
||||||
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
check := sources[len(sources)-1].VersionCheck
|
check := sources[len(sources)-1].VersionCheck
|
||||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||||
@@ -72,7 +86,7 @@ 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"
|
||||||
@@ -90,11 +104,7 @@ func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
|||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
||||||
}
|
}
|
||||||
want := 2
|
if len(result.Config["records"].([]any)) != 2 {
|
||||||
if grouped {
|
|
||||||
want = 1
|
|
||||||
}
|
|
||||||
if len(result.Config["records"].([]any)) != want {
|
|
||||||
t.Fatal("version path changed composition")
|
t.Fatal("version path changed composition")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,7 +636,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||||
}}
|
}}
|
||||||
path := filepath.Join(t.TempDir(), "main.yml")
|
path := filepath.Join(t.TempDir(), "main.yml")
|
||||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n"
|
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n required: true\n"
|
||||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,7 +5,7 @@ go 1.26.0
|
|||||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
|
|||||||
293
internal/engine/acquisition.go
Normal file
293
internal/engine/acquisition.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
type acquisitionPolicy struct {
|
||||||
|
importDefaults ImportDefaults
|
||||||
|
sequencePolicy sequencePolicy
|
||||||
|
importLimits importLimits
|
||||||
|
}
|
||||||
|
|
||||||
|
type acquisitionState struct {
|
||||||
|
accounting *importAccounting
|
||||||
|
versionState *sourceVersionState
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierAcquisitionState struct {
|
||||||
|
visited map[resourceIdentity]importRoute
|
||||||
|
hostToken *namespaceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputAcquisitionRequest struct {
|
||||||
|
input loadInput
|
||||||
|
role sourceVersionRole
|
||||||
|
directiveContext rootDirectiveContext
|
||||||
|
protectionRule *VersionRule
|
||||||
|
ordinaryOverlay string
|
||||||
|
}
|
||||||
|
|
||||||
|
type importAcquisitionRequest struct {
|
||||||
|
input inputAcquisitionRequest
|
||||||
|
main parsedSource
|
||||||
|
processing *sourceVersionProcessingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||||
|
source := request.input.source
|
||||||
|
destination := request.input.destination
|
||||||
|
accounting := state.accounting
|
||||||
|
versionState := state.versionState
|
||||||
|
sourceID := "source-" + strconv.Itoa(accounting.sources+1)
|
||||||
|
selectedSource := source
|
||||||
|
if source.kind == sourceSearch {
|
||||||
|
resolved, failedSource, diagnostics := resolveSearchInput(source, sourceID, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
if failedSource != nil {
|
||||||
|
failedSource.Destination = destination.clone()
|
||||||
|
failedSource.placementMode = request.input.placementMode
|
||||||
|
}
|
||||||
|
return assembledInput{source: resolved, sources: sourceInfos(failedSource), versionState: versionState, accounting: accounting}, diagnostics
|
||||||
|
}
|
||||||
|
selectedSource = resolved
|
||||||
|
if request.input.filesystem != nil {
|
||||||
|
if diagnostics := filesystemOptionDiagnostics(selectedSource, request.input.filesystem); len(diagnostics) != 0 {
|
||||||
|
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedSource.kind == sourceNone && activeOverlay(request.ordinaryOverlay) && request.input.filesystem == nil {
|
||||||
|
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
limits := policy.importLimits
|
||||||
|
parseLimits := defaultParseLimits()
|
||||||
|
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||||
|
parsed, diagnostics := parseSource(selectedSource, sourceID, parseLimits)
|
||||||
|
if parsed.source != nil {
|
||||||
|
parsed.source.Destination = destination.clone()
|
||||||
|
parsed.source.placementMode = request.input.placementMode
|
||||||
|
}
|
||||||
|
input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
input.sources = sourceInfos(parsed.source)
|
||||||
|
// Mapping value issues already identify application content. Other early
|
||||||
|
// failures describe source syntax or access, not a placed value.
|
||||||
|
if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 {
|
||||||
|
for index := range diagnostics {
|
||||||
|
relative := diagnostics[index].Path
|
||||||
|
segments := clonePathSegments(destination.segments, len(relative.segments))
|
||||||
|
diagnostics[index].Path = Path{segments: append(segments, relative.segments...)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
directiveContext := request.directiveContext
|
||||||
|
if directiveContext == 0 {
|
||||||
|
directiveContext = rootDirectiveContextImportsOnly
|
||||||
|
}
|
||||||
|
if !request.input.imports && parsed.document != nil {
|
||||||
|
var directiveDiagnostics []Diagnostic
|
||||||
|
if directiveContext == rootDirectiveContextSource || directiveContext == rootDirectiveContextLayered {
|
||||||
|
directiveDiagnostics = disabledRootImportDiagnostics(parsed.document)
|
||||||
|
} else {
|
||||||
|
directiveDiagnostics = disabledDirectiveDiagnostics(parsed.document, Path{})
|
||||||
|
}
|
||||||
|
if len(directiveDiagnostics) != 0 {
|
||||||
|
return input, directiveDiagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.directiveContext = directiveContext
|
||||||
|
versionProcessing, diagnostics := processAcquiredInputVersion(parsed, request, versionState)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
request.input.source = selectedSource
|
||||||
|
if request.input.imports && selectedSource.kind != sourceNone {
|
||||||
|
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
||||||
|
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
if input.composition != nil {
|
||||||
|
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||||
|
input.document = input.composition.root
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input, diagnostics = acquireStandaloneInput(input, request, versionProcessing, policy, tier)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completeAcquisitionContexts(input, request, tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireStandaloneInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||||
|
parsed := input.parsed
|
||||||
|
input.sources = sourceInfos(parsed.source)
|
||||||
|
if processing != nil {
|
||||||
|
input.document = processing.payload
|
||||||
|
}
|
||||||
|
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||||
|
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
input.resolvedFilesystem = &resolved
|
||||||
|
}
|
||||||
|
if request.input.imports || input.document == nil {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := policy.importLimits
|
||||||
|
placement := request.input.destination
|
||||||
|
documentLimits := defaultParseLimits().document
|
||||||
|
_, placed, err := placeDocumentWithMode(input.document, placement, request.input.placementMode, input.document.Location, documentLimits.Depth, documentLimits.Nodes)
|
||||||
|
if err != nil {
|
||||||
|
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||||
|
}
|
||||||
|
localPayload := input.document
|
||||||
|
input.document = placed
|
||||||
|
identity := importKeyForMainSource(parsed.source)
|
||||||
|
var sequenceDeclarations []sequenceDeclarationSpec
|
||||||
|
if processing != nil {
|
||||||
|
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
|
||||||
|
}
|
||||||
|
input.acquisition = []acquisitionScope{{
|
||||||
|
owner: identity,
|
||||||
|
destination: placement.clone(),
|
||||||
|
contributions: []acquiredContribution{{
|
||||||
|
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||||
|
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||||
|
document: placed, destination: placement.clone(), processing: processing,
|
||||||
|
sequenceDeclarations: sequenceDeclarations,
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
rawNodes := countInputNodes(parsed.document)
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(placement, request.input.placementMode)
|
||||||
|
accounting := input.accounting
|
||||||
|
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||||
|
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||||
|
}
|
||||||
|
if diagnostic := registerAcquiredSource(input.source, request.input.filesystem, parsed.source, tier.visited, tier.hostToken); diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
accounting.sources++
|
||||||
|
accounting.bytes += parsed.bytes
|
||||||
|
accounting.nodes += rawNodes + placementNodes
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeAcquisitionContexts(input assembledInput, request inputAcquisitionRequest, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||||
|
if activeOverlay(request.ordinaryOverlay) && !request.input.imports {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (input.source.kind == sourceFile || request.input.filesystem != nil || nativeContext(input.source, request.input.filesystem)) {
|
||||||
|
resolved, diagnostic := resolveFilesystemContext(input.source, request.input.filesystem, input.parsed, tier.hostToken)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
input.resolvedFilesystem = &resolved
|
||||||
|
}
|
||||||
|
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
input.contexts = contexts
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRequest, state *sourceVersionState) (*sourceVersionProcessingResult, []Diagnostic) {
|
||||||
|
sourceID := ""
|
||||||
|
if parsed.source != nil {
|
||||||
|
sourceID = parsed.source.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var result sourceVersionProcessingResult
|
||||||
|
hasResult := false
|
||||||
|
updateMainProcessing := false
|
||||||
|
switch request.directiveContext {
|
||||||
|
case rootDirectiveContextSource:
|
||||||
|
if state != nil {
|
||||||
|
processed, diagnostics := state.processWithDirectiveContext(
|
||||||
|
parsed.document, request.role, request.input.destination, sourceID,
|
||||||
|
request.input.placementMode, request.directiveContext,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
result = processed
|
||||||
|
updateMainProcessing = request.role == sourceVersionMain
|
||||||
|
} else {
|
||||||
|
result = processSourceVersionWithDirectiveContext(
|
||||||
|
parsed.document, request.protectionRule, request.role,
|
||||||
|
request.input.destination, sourceVersionModeDefault, request.directiveContext,
|
||||||
|
)
|
||||||
|
if len(result.diagnostics) != 0 {
|
||||||
|
return nil, result.diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasResult = true
|
||||||
|
case rootDirectiveContextLayered:
|
||||||
|
extracted, diagnostics := extractRootDirectives(parsed.document, request.directiveContext)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
result = sourceVersionProcessingResult{
|
||||||
|
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
|
||||||
|
directiveContext: request.directiveContext,
|
||||||
|
}
|
||||||
|
hasResult = true
|
||||||
|
if state != nil && state.mode != sourceVersionModeDefault {
|
||||||
|
processed, diagnostics := state.process(
|
||||||
|
extracted.body, request.role, request.input.destination,
|
||||||
|
sourceID, request.input.placementMode,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
processed.imports = extracted.imports
|
||||||
|
processed.sequences = append(cloneSequenceDeclarations(extracted.sequences), processed.sequences...)
|
||||||
|
processed.directiveContext = request.directiveContext
|
||||||
|
result = processed
|
||||||
|
updateMainProcessing = request.role == sourceVersionMain
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if state != nil && state.mode != sourceVersionModeDefault {
|
||||||
|
processed, diagnostics := state.process(
|
||||||
|
parsed.document, request.role, request.input.destination,
|
||||||
|
sourceID, request.input.placementMode,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
result = processed
|
||||||
|
hasResult = true
|
||||||
|
updateMainProcessing = request.role == sourceVersionMain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasResult {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if updateMainProcessing {
|
||||||
|
completed := result
|
||||||
|
state.mainProcessing = &completed
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
|
||||||
|
if visited == nil || info == nil || info.Path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
identity := resourceIdentity{namespace: hostToken, path: info.Path}
|
||||||
|
if source.kind == sourceFilesystem {
|
||||||
|
identity.namespace = source.namespace.token
|
||||||
|
} else if context != nil && context.FS != nil {
|
||||||
|
identity.namespace = context.FS.namespace.token
|
||||||
|
}
|
||||||
|
if previous, repeated := visited[identity]; repeated {
|
||||||
|
value := importRepeatedDiagnostic(Location{SourceID: info.ID}, previous, importRoute{})
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
visited[identity] = importRoute{canonical: info.Path}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
73
internal/engine/callback_panic_test.go
Normal file
73
internal/engine/callback_panic_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func capturePanic(call func()) (recovered any) {
|
||||||
|
defer func() { recovered = recover() }()
|
||||||
|
call()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirePanicValue(t *testing.T, want any, call func()) {
|
||||||
|
t.Helper()
|
||||||
|
if got := capturePanic(call); got != want {
|
||||||
|
t.Fatalf("panic = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackContextsCloseWhenCallbacksPanic(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("resolution", func(t *testing.T) {
|
||||||
|
sentinel := errors.New("resolution panic sentinel")
|
||||||
|
var retained *Resolution
|
||||||
|
got := capturePanic(func() {
|
||||||
|
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
retained = resolution
|
||||||
|
panic(sentinel)
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
if got != sentinel {
|
||||||
|
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||||
|
}
|
||||||
|
requirePanicValue(t, closedCallbackContextMessage, func() { retained.IsExplicit(Keys("value")) })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("validation", func(t *testing.T) {
|
||||||
|
sentinel := errors.New("validation panic sentinel")
|
||||||
|
var retained *Validation
|
||||||
|
got := capturePanic(func() {
|
||||||
|
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) {
|
||||||
|
retained = validation
|
||||||
|
panic(sentinel)
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
if got != sentinel {
|
||||||
|
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||||
|
}
|
||||||
|
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Error(Keys("value"), "too late") })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("version", func(t *testing.T) {
|
||||||
|
sentinel := errors.New("version panic sentinel")
|
||||||
|
var retained *Validation
|
||||||
|
got := capturePanic(func() {
|
||||||
|
_, _ = testLoad[config](Bytes("version.yml", []byte("version: v1\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
||||||
|
retained = validation
|
||||||
|
panic(sentinel)
|
||||||
|
}},
|
||||||
|
}})
|
||||||
|
})
|
||||||
|
if got != sentinel {
|
||||||
|
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||||
|
}
|
||||||
|
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Warning(Keys("version"), "too late") })
|
||||||
|
})
|
||||||
|
}
|
||||||
106
internal/engine/collection_ancestor_test.go
Normal file
106
internal/engine/collection_ancestor_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNestedCollectionInitializationIsIndependentOfAncestorPresence(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, ancestor := range []string{"{}", "group: {}", "group: null"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
mode, key, value string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{mode: "append", value: "[{id: a}]"},
|
||||||
|
{mode: "upsert", value: "[{id: a}]"},
|
||||||
|
{mode: "append", value: "[]"},
|
||||||
|
{mode: "upsert", value: "[]"},
|
||||||
|
{mode: "append", key: ", key: id", value: "{}"},
|
||||||
|
{mode: "upsert", key: ", key: id", value: "null"},
|
||||||
|
{mode: "append", key: ", key: id", value: "[{id: a}]", valid: true},
|
||||||
|
{mode: "upsert", key: ", key: id", value: "[]", valid: true},
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s/%s/%s", ancestor, test.mode, test.key, test.value), func(t *testing.T) {
|
||||||
|
main := "configkit:\n sequences: [{destination: [group, items], mode: " + test.mode + test.key + "}]\ngroup: {items: " + test.value + "}\n"
|
||||||
|
loaded, err := testGroupedLoad(loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: Bytes("main", []byte(main))},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte(ancestor))}}}},
|
||||||
|
})
|
||||||
|
if test.valid {
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("valid initialization failed: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) || loaded != nil {
|
||||||
|
t.Fatalf("invalid collection operation succeeded: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/group/items" {
|
||||||
|
t.Fatalf("operation diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddedAncestorTransfersNestedRecordPolicies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type record struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Steps []map[string]any `yaml:"steps"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Group struct {
|
||||||
|
Items []record `yaml:"items"`
|
||||||
|
} `yaml:"group"`
|
||||||
|
}
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte(`configkit:
|
||||||
|
sequences: [{destination: [group, items], key: id}]
|
||||||
|
imports: [{path: base.yml, destination: [group, items], placement: sequence-item}]
|
||||||
|
group:
|
||||||
|
items: [{id: child, extends: {id: base}, steps: [{name: child}]}]
|
||||||
|
`)},
|
||||||
|
"base.yml": {Data: []byte("configkit:\n sequences: [{destination: [steps], key: name}]\nid: base\nsteps: [{name: base}]\n")},
|
||||||
|
}
|
||||||
|
loaded, err := testGroupedLoad(loadRequest[config]{
|
||||||
|
main: loadInput{source: NewFilesystem("catalog", files).File("main.yml"), imports: true},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("{}"))}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, item := range loaded.Config.Group.Items {
|
||||||
|
if item.ID == "child" {
|
||||||
|
if len(item.Steps) != 2 || item.Steps[0]["name"] != "base" || item.Steps[1]["name"] != "child" {
|
||||||
|
t.Fatalf("record-local identity was lost when adding its ancestor: %#v", item.Steps)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("missing child record")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceChecksCollectionOperationUnderAddedAncestor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := testLoad[map[string]any](Bytes("inheritance", []byte(`configkit:
|
||||||
|
sequences: [{destination: [child, group, items], mode: append}]
|
||||||
|
base: {}
|
||||||
|
child:
|
||||||
|
extends: base
|
||||||
|
group: {items: [{id: a}]}
|
||||||
|
`)))
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("missing nested operation error: %v", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/child/group/items" {
|
||||||
|
t.Fatalf("nested inheritance operation diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
internal/engine/collection_lookup_test.go
Normal file
98
internal/engine/collection_lookup_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) {
|
||||||
|
var baseBody, overlayBody strings.Builder
|
||||||
|
basePolicies := collectionPolicyScope{}
|
||||||
|
var declarations []sequenceDeclarationSpec
|
||||||
|
for index := range 64 {
|
||||||
|
name := fmt.Sprintf("group/%03d~", index)
|
||||||
|
fmt.Fprintf(&baseBody, "%s: {items: [{id: base}], retained: [{id: retained}], scalar: pending}\n", name)
|
||||||
|
fmt.Fprintf(&overlayBody, "%s: {items: [{id: overlay}], added: true}\n", name)
|
||||||
|
basePolicies.set(Keys(name, "items"), collectionPolicy{
|
||||||
|
identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
})
|
||||||
|
for _, key := range []string{"retained", "missing", "scalar"} {
|
||||||
|
declarations = append(declarations, sequenceDeclarationSpec{
|
||||||
|
destination: Keys(name, key), identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
mode: sequenceModeAppend, modePresent: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base, diagnostics := composeDocuments(parseCompositionDocument(t, "base", baseBody.String()))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
base.collectionPolicies = basePolicies
|
||||||
|
assembly := newAssemblyAccumulator(base)
|
||||||
|
overlay := parseCompositionDocument(t, "overlay", overlayBody.String())
|
||||||
|
diagnostics = assembly.applyOverlayWithCollectionPolicy(overlay, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, declarations)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
lookup := buildDocumentLookup(assembly.current.root)
|
||||||
|
for index := range 64 {
|
||||||
|
name := fmt.Sprintf("group/%03d~", index)
|
||||||
|
items, found := documentAtPathWithLookup(assembly.current.root, Keys(name, "items"), lookup)
|
||||||
|
if !found || items.Kind != document.Sequence || len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "overlay" {
|
||||||
|
t.Fatalf("collection %s did not append in order", name)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"retained", "missing", "scalar"} {
|
||||||
|
policy, found := assembly.current.collectionPolicies.at(Keys(name, key))
|
||||||
|
if !found || !policy.identityPresent || !policy.explicitModePresent || policy.explicitMode != sequenceModeAppend {
|
||||||
|
t.Fatalf("retained/pending declaration lost at %s/%s", name, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, found := documentAtPathWithLookup(assembly.current.root, Keys(name, "missing"), lookup); found {
|
||||||
|
t.Fatal("pending declaration created a value")
|
||||||
|
}
|
||||||
|
if origin := assembly.current.origins[Keys(name, "items").Index(1).Key("id").String()]; origin.Layer != LayerOverlay {
|
||||||
|
t.Fatal("appended item lost overlay attribution")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceBroadCollectionsInOneDefinition(t *testing.T) {
|
||||||
|
var body strings.Builder
|
||||||
|
policies := collectionPolicyScope{}
|
||||||
|
for _, definition := range []string{"base", "child"} {
|
||||||
|
fmt.Fprintf(&body, "%s:\n", definition)
|
||||||
|
if definition == "child" {
|
||||||
|
body.WriteString(" extends: base\n")
|
||||||
|
}
|
||||||
|
for index := range 64 {
|
||||||
|
name := fmt.Sprintf("group/%03d~", index)
|
||||||
|
fmt.Fprintf(&body, " %s: {items: [{id: %s}]}\n", name, definition)
|
||||||
|
policy := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
if definition == "child" {
|
||||||
|
policy.explicitMode, policy.explicitModePresent = sequenceModeAppend, true
|
||||||
|
}
|
||||||
|
policies.set(Keys(definition, name, "items"), policy)
|
||||||
|
policies.set(Keys(definition, name, "pending"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root := parseCompositionDocument(t, "wide-collections", body.String())
|
||||||
|
result, diagnostics := expandInheritanceWithPolicies(root, nil, policies, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
lookup := buildDocumentLookup(result.root)
|
||||||
|
for index := range 64 {
|
||||||
|
name := fmt.Sprintf("group/%03d~", index)
|
||||||
|
items, found := documentAtPathWithLookup(result.root, Keys("child", name, "items"), lookup)
|
||||||
|
if !found || len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "child" {
|
||||||
|
t.Fatalf("inherited collection %s did not append in order", name)
|
||||||
|
}
|
||||||
|
if policy, found := result.collectionPolicies.at(Keys("child", name, "pending")); !found || !policy.identityPresent {
|
||||||
|
t.Fatalf("pending inherited policy lost at %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
internal/engine/collection_policy.go
Normal file
201
internal/engine/collection_policy.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
type collectionPolicy struct {
|
||||||
|
identityFields []string
|
||||||
|
identityPresent bool
|
||||||
|
identityLocation *Location
|
||||||
|
explicitMode sequenceMode
|
||||||
|
explicitModePresent bool
|
||||||
|
modeLocation *Location
|
||||||
|
recordOrder []*collectionRecordOwner
|
||||||
|
recordPolicies map[*collectionRecordOwner]collectionPolicyScope
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionPolicyResolution struct {
|
||||||
|
policy collectionPolicy
|
||||||
|
operation sequenceMode
|
||||||
|
modeDeclarationPresent bool
|
||||||
|
applicationIdentityPresent bool
|
||||||
|
effectiveIdentityFields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCollectionPolicy reconciles declarations for one destination before
|
||||||
|
// peers are composed. The caller supplies only lower policy that is applicable
|
||||||
|
// to this destination; record survival and ownership are resolved elsewhere.
|
||||||
|
func resolveCollectionPolicy(
|
||||||
|
destination Path,
|
||||||
|
declarations []sequenceDeclarationSpec,
|
||||||
|
applicationIdentityFields []string,
|
||||||
|
lower *collectionPolicy,
|
||||||
|
) (collectionPolicyResolution, []Diagnostic) {
|
||||||
|
normalizedDeclarations := cloneSequenceDeclarations(declarations)
|
||||||
|
for index := range normalizedDeclarations {
|
||||||
|
declaration := &normalizedDeclarations[index]
|
||||||
|
if !declaration.identityPresent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields, err := normalizeSequenceIdentityFields(declaration.identityFields)
|
||||||
|
if err != nil {
|
||||||
|
return collectionPolicyResolution{}, []Diagnostic{{
|
||||||
|
Code: CodeSequenceInvalid, Severity: SeverityError, Path: destination.clone(),
|
||||||
|
Message: "collection identity declaration requires at least one field",
|
||||||
|
Primary: collectionDeclarationLocation(declaration.identityLocation, declaration.location),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
declaration.identityFields = fields
|
||||||
|
}
|
||||||
|
normalizedApplicationIdentityFields := cloneStrings(applicationIdentityFields)
|
||||||
|
if len(applicationIdentityFields) != 0 {
|
||||||
|
var err error
|
||||||
|
normalizedApplicationIdentityFields, err = normalizeSequenceIdentityFields(applicationIdentityFields)
|
||||||
|
if err != nil {
|
||||||
|
return collectionPolicyResolution{}, []Diagnostic{{
|
||||||
|
Code: CodeOptionsInvalid, Severity: SeverityError, Path: destination.clone(),
|
||||||
|
Message: "application collection identity fields are invalid",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := collectionPolicyResolution{
|
||||||
|
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
var declaredIdentityFields []string
|
||||||
|
var declaredIdentityLocation *Location
|
||||||
|
var declaredMode sequenceMode
|
||||||
|
var declaredModeLocation *Location
|
||||||
|
for _, declaration := range normalizedDeclarations {
|
||||||
|
if declaration.identityPresent {
|
||||||
|
if declaredIdentityFields == nil {
|
||||||
|
declaredIdentityFields = cloneStrings(declaration.identityFields)
|
||||||
|
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)
|
||||||
|
} else if !equalSequenceIdentityFields(declaredIdentityFields, declaration.identityFields) {
|
||||||
|
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||||
|
destination, declaredIdentityLocation,
|
||||||
|
collectionDeclarationLocation(declaration.identityLocation, declaration.location),
|
||||||
|
"collection identity declarations conflict",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if declaration.modePresent {
|
||||||
|
result.modeDeclarationPresent = true
|
||||||
|
if declaredMode == 0 {
|
||||||
|
declaredMode = declaration.mode
|
||||||
|
declaredModeLocation = collectionDeclarationLocation(declaration.modeLocation, declaration.location)
|
||||||
|
} else if declaredMode != declaration.mode {
|
||||||
|
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||||
|
destination, declaredModeLocation,
|
||||||
|
collectionDeclarationLocation(declaration.modeLocation, declaration.location),
|
||||||
|
"collection operation declarations conflict",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return collectionPolicyResolution{}, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower != nil && lower.identityPresent && declaredIdentityFields != nil && !equalSequenceIdentityFields(lower.identityFields, declaredIdentityFields) {
|
||||||
|
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||||
|
destination, declaredIdentityLocation,
|
||||||
|
collectionDeclarationLocation(lower.identityLocation, Location{}),
|
||||||
|
"collection identity declaration conflicts with retained identity",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if result.applicationIdentityPresent {
|
||||||
|
if declaredIdentityFields != nil && !equalSequenceIdentityFields(normalizedApplicationIdentityFields, declaredIdentityFields) {
|
||||||
|
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||||
|
destination, declaredIdentityLocation, nil,
|
||||||
|
"collection identity declaration conflicts with application constraint",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if lower != nil && lower.identityPresent && !equalSequenceIdentityFields(normalizedApplicationIdentityFields, lower.identityFields) {
|
||||||
|
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||||
|
destination, declaredIdentityLocation,
|
||||||
|
collectionDeclarationLocation(lower.identityLocation, Location{}),
|
||||||
|
"retained collection identity conflicts with application constraint",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return collectionPolicyResolution{}, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
if declaredIdentityFields != nil {
|
||||||
|
result.policy.identityFields = declaredIdentityFields
|
||||||
|
result.policy.identityPresent = true
|
||||||
|
result.policy.identityLocation = cloneLocation(declaredIdentityLocation)
|
||||||
|
} else if lower != nil && lower.identityPresent {
|
||||||
|
result.policy.identityFields = cloneStrings(lower.identityFields)
|
||||||
|
result.policy.identityPresent = true
|
||||||
|
result.policy.identityLocation = cloneLocation(lower.identityLocation)
|
||||||
|
}
|
||||||
|
if result.applicationIdentityPresent {
|
||||||
|
result.effectiveIdentityFields = cloneStrings(normalizedApplicationIdentityFields)
|
||||||
|
} else if result.policy.identityPresent {
|
||||||
|
result.effectiveIdentityFields = cloneStrings(result.policy.identityFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.modeDeclarationPresent {
|
||||||
|
result.policy.explicitMode = declaredMode
|
||||||
|
result.policy.explicitModePresent = true
|
||||||
|
result.policy.modeLocation = cloneLocation(declaredModeLocation)
|
||||||
|
} else if lower != nil && lower.explicitModePresent {
|
||||||
|
result.policy.explicitMode = lower.explicitMode
|
||||||
|
result.policy.explicitModePresent = true
|
||||||
|
result.policy.modeLocation = cloneLocation(lower.modeLocation)
|
||||||
|
}
|
||||||
|
result.operation = collectionOperationMode(declaredMode, result.policy.explicitMode, len(result.effectiveIdentityFields) != 0)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionOperationMode(current, retained sequenceMode, identified bool) sequenceMode {
|
||||||
|
if current != 0 {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
if retained != 0 {
|
||||||
|
return retained
|
||||||
|
}
|
||||||
|
if identified {
|
||||||
|
return sequenceModeUpsert
|
||||||
|
}
|
||||||
|
return sequenceModeReplace
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSequenceDeclarations(declarations []sequenceDeclarationSpec) []sequenceDeclarationSpec {
|
||||||
|
if declarations == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]sequenceDeclarationSpec, len(declarations))
|
||||||
|
for index, declaration := range declarations {
|
||||||
|
cloned[index] = declaration
|
||||||
|
cloned[index].path = declaration.path.clone()
|
||||||
|
cloned[index].destination = declaration.destination.clone()
|
||||||
|
cloned[index].identityFields = cloneStrings(declaration.identityFields)
|
||||||
|
cloned[index].identityLocation = cloneLocation(declaration.identityLocation)
|
||||||
|
cloned[index].modeLocation = cloneLocation(declaration.modeLocation)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionDeclarationLocation(specific *Location, fallback Location) *Location {
|
||||||
|
if specific != nil {
|
||||||
|
return cloneLocation(specific)
|
||||||
|
}
|
||||||
|
if fallback != (Location{}) {
|
||||||
|
return cloneLocation(&fallback)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionPolicyConflictDiagnostic(path Path, primary, related *Location, message string) Diagnostic {
|
||||||
|
diagnostic := Diagnostic{
|
||||||
|
Code: CodeSequenceConflict, Severity: SeverityError,
|
||||||
|
Path: path.clone(), Message: message, Primary: cloneLocation(primary),
|
||||||
|
}
|
||||||
|
if related != nil {
|
||||||
|
diagnostic.Related = []Location{*cloneLocation(related)}
|
||||||
|
}
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
137
internal/engine/collection_policy_index.go
Normal file
137
internal/engine/collection_policy_index.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// collectionPolicyRetirementIndex locates only assembly policies whose YAML
|
||||||
|
// identity or record-owned state can be cleared by an ancestor replacement.
|
||||||
|
// Mapping keys and sequence indexes remain distinct through pathSegment keys.
|
||||||
|
type collectionPolicyRetirementIndex struct {
|
||||||
|
children map[pathSegment]*collectionPolicyRetirementIndex
|
||||||
|
entryKey string
|
||||||
|
hasEntry bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollectionPolicyRetirementIndex(scope collectionPolicyScope) *collectionPolicyRetirementIndex {
|
||||||
|
index := &collectionPolicyRetirementIndex{}
|
||||||
|
for _, entry := range scope.collections {
|
||||||
|
if collectionPolicyHasRetirementState(entry.policy) {
|
||||||
|
index.set(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) set(entry collectionPolicyEntry) {
|
||||||
|
node := index
|
||||||
|
for _, segment := range entry.destination.segments {
|
||||||
|
if node.children == nil {
|
||||||
|
node.children = make(map[pathSegment]*collectionPolicyRetirementIndex)
|
||||||
|
}
|
||||||
|
if node.children[segment] == nil {
|
||||||
|
node.children[segment] = &collectionPolicyRetirementIndex{}
|
||||||
|
}
|
||||||
|
node = node.children[segment]
|
||||||
|
}
|
||||||
|
node.entryKey = entry.destination.String()
|
||||||
|
node.hasEntry = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) remove(destination Path) {
|
||||||
|
if index == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes := make([]*collectionPolicyRetirementIndex, 1, len(destination.segments)+1)
|
||||||
|
nodes[0] = index
|
||||||
|
node := index
|
||||||
|
for _, segment := range destination.segments {
|
||||||
|
node = node.children[segment]
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
node.entryKey = ""
|
||||||
|
node.hasEntry = false
|
||||||
|
pruneCollectionPolicyIndex(nodes, destination.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) retire(scope *collectionPolicyScope, ancestor Path) {
|
||||||
|
if index == nil || scope == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes := make([]*collectionPolicyRetirementIndex, 1, len(ancestor.segments)+1)
|
||||||
|
nodes[0] = index
|
||||||
|
node := index
|
||||||
|
for _, segment := range ancestor.segments {
|
||||||
|
node = node.children[segment]
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
index.clearSubtree(scope, node)
|
||||||
|
pruneCollectionPolicyIndex(nodes, ancestor.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) clearSubtree(scope *collectionPolicyScope, node *collectionPolicyRetirementIndex) {
|
||||||
|
if node.hasEntry {
|
||||||
|
if entry, found := scope.collections[node.entryKey]; found {
|
||||||
|
clearCollectionIdentity(&entry.policy)
|
||||||
|
scope.collections[node.entryKey] = entry
|
||||||
|
}
|
||||||
|
node.entryKey = ""
|
||||||
|
node.hasEntry = false
|
||||||
|
}
|
||||||
|
for segment, child := range node.children {
|
||||||
|
index.clearSubtree(scope, child)
|
||||||
|
if child.empty() {
|
||||||
|
delete(node.children, segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(node.children) == 0 {
|
||||||
|
node.children = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) empty() bool {
|
||||||
|
return index == nil || !index.hasEntry && len(index.children) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneCollectionPolicyIndex(nodes []*collectionPolicyRetirementIndex, segments []pathSegment) {
|
||||||
|
for position := len(segments) - 1; position >= 0; position-- {
|
||||||
|
child := nodes[position+1]
|
||||||
|
if !child.empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(nodes[position].children, segments[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionPolicyHasRetirementState(policy collectionPolicy) bool {
|
||||||
|
return policy.identityFields != nil || policy.identityPresent || policy.identityLocation != nil ||
|
||||||
|
policy.recordOrder != nil || policy.recordPolicies != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assembly *assemblyAccumulator) setCollectionPolicy(destination Path, policy collectionPolicy) {
|
||||||
|
if assembly == nil || assembly.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assembly.collectionPolicyRetirement == nil {
|
||||||
|
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||||
|
}
|
||||||
|
assembly.current.collectionPolicies.set(destination, policy)
|
||||||
|
entry := assembly.current.collectionPolicies.collections[destination.String()]
|
||||||
|
if collectionPolicyHasRetirementState(entry.policy) {
|
||||||
|
assembly.collectionPolicyRetirement.set(entry)
|
||||||
|
} else {
|
||||||
|
assembly.collectionPolicyRetirement.remove(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assembly *assemblyAccumulator) retireCollectionPoliciesAtOrBelow(ancestor Path) {
|
||||||
|
if assembly == nil || assembly.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assembly.collectionPolicyRetirement == nil {
|
||||||
|
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||||
|
}
|
||||||
|
assembly.collectionPolicyRetirement.retire(&assembly.current.collectionPolicies, ancestor)
|
||||||
|
}
|
||||||
258
internal/engine/collection_policy_state.go
Normal file
258
internal/engine/collection_policy_state.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type collectionPolicyScope struct {
|
||||||
|
collections map[string]collectionPolicyEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionPolicyEntry struct {
|
||||||
|
destination Path
|
||||||
|
policy collectionPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionRecordPolicy struct {
|
||||||
|
owner *collectionRecordOwner
|
||||||
|
scope collectionPolicyScope
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nonzero field ensures simultaneously live record owners have distinct
|
||||||
|
// addresses. The token is per-record ownership, not a source or identity key.
|
||||||
|
type collectionRecordOwner struct {
|
||||||
|
marker byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectedCollectionPolicy struct {
|
||||||
|
path Path
|
||||||
|
policy collectionPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollectionRecordPolicy() collectionRecordPolicy {
|
||||||
|
return collectionRecordPolicy{owner: &collectionRecordOwner{marker: 1}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainedCollectionRecordPolicy(record collectionRecordPolicy) collectionRecordPolicy {
|
||||||
|
return collectionRecordPolicy{
|
||||||
|
owner: record.owner,
|
||||||
|
scope: cloneCollectionPolicyScope(record.scope),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacedCollectionRecordPolicy() collectionRecordPolicy {
|
||||||
|
return newCollectionRecordPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (policy *collectionPolicy) setRecordPolicy(record collectionRecordPolicy) {
|
||||||
|
if record.owner == nil {
|
||||||
|
record.owner = newCollectionRecordPolicy().owner
|
||||||
|
}
|
||||||
|
if policy.recordPolicies == nil {
|
||||||
|
policy.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope)
|
||||||
|
}
|
||||||
|
if _, exists := policy.recordPolicies[record.owner]; !exists {
|
||||||
|
policy.recordOrder = append(policy.recordOrder, record.owner)
|
||||||
|
}
|
||||||
|
policy.recordPolicies[record.owner] = cloneCollectionPolicyScope(record.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (policy *collectionPolicy) setRecordPolicyAt(index int, record collectionRecordPolicy) {
|
||||||
|
if index < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.owner == nil {
|
||||||
|
record.owner = newCollectionRecordPolicy().owner
|
||||||
|
}
|
||||||
|
if policy.recordPolicies == nil {
|
||||||
|
policy.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope)
|
||||||
|
}
|
||||||
|
for len(policy.recordOrder) <= index {
|
||||||
|
owner := newCollectionRecordPolicy().owner
|
||||||
|
policy.recordOrder = append(policy.recordOrder, owner)
|
||||||
|
policy.recordPolicies[owner] = collectionPolicyScope{}
|
||||||
|
}
|
||||||
|
delete(policy.recordPolicies, policy.recordOrder[index])
|
||||||
|
policy.recordOrder[index] = record.owner
|
||||||
|
policy.recordPolicies[record.owner] = cloneCollectionPolicyScope(record.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (policy collectionPolicy) recordPolicyAt(index int) (collectionRecordPolicy, bool) {
|
||||||
|
if index < 0 || index >= len(policy.recordOrder) {
|
||||||
|
return collectionRecordPolicy{}, false
|
||||||
|
}
|
||||||
|
owner := policy.recordOrder[index]
|
||||||
|
scope, ok := policy.recordPolicies[owner]
|
||||||
|
if !ok {
|
||||||
|
return collectionRecordPolicy{}, false
|
||||||
|
}
|
||||||
|
return collectionRecordPolicy{owner: owner, scope: cloneCollectionPolicyScope(scope)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionPolicyForReplacement(lower *collectionPolicy, current collectionPolicy) collectionPolicy {
|
||||||
|
result := cloneCollectionPolicy(current)
|
||||||
|
if !current.explicitModePresent && lower != nil && lower.explicitModePresent {
|
||||||
|
result.explicitMode = lower.explicitMode
|
||||||
|
result.explicitModePresent = true
|
||||||
|
result.modeLocation = cloneLocation(lower.modeLocation)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectionPolicyWithoutValue keeps identity and record ownership because no
|
||||||
|
// collection value was replaced. A key-only declaration can remain pending
|
||||||
|
// when no sequence is present, or validate the retained sequence immediately.
|
||||||
|
func collectionPolicyWithoutValue(
|
||||||
|
lower *collectionPolicy,
|
||||||
|
current collectionPolicy,
|
||||||
|
retainedValue *document.Node,
|
||||||
|
path Path,
|
||||||
|
) (collectionPolicy, []Diagnostic) {
|
||||||
|
result := collectionPolicy{}
|
||||||
|
if lower != nil {
|
||||||
|
result = cloneCollectionPolicy(*lower)
|
||||||
|
}
|
||||||
|
if lower != nil && lower.identityPresent && current.identityPresent && !equalSequenceIdentityFields(lower.identityFields, current.identityFields) {
|
||||||
|
return collectionPolicy{}, []Diagnostic{collectionPolicyConflictDiagnostic(
|
||||||
|
path, current.identityLocation, lower.identityLocation,
|
||||||
|
"collection identity declaration conflicts with retained identity",
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
if current.identityPresent {
|
||||||
|
result.identityFields = cloneStrings(current.identityFields)
|
||||||
|
result.identityPresent = true
|
||||||
|
result.identityLocation = cloneLocation(current.identityLocation)
|
||||||
|
}
|
||||||
|
if current.explicitModePresent {
|
||||||
|
result.explicitMode = current.explicitMode
|
||||||
|
result.explicitModePresent = true
|
||||||
|
result.modeLocation = cloneLocation(current.modeLocation)
|
||||||
|
}
|
||||||
|
if current.identityPresent && retainedValue != nil && retainedValue.Kind == document.Sequence {
|
||||||
|
_, diagnostics := validateSequenceIdentity(retainedValue, path, result.identityFields, sequenceIdentityDiagnosticCodes{
|
||||||
|
invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict,
|
||||||
|
})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return collectionPolicy{}, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scope *collectionPolicyScope) set(destination Path, policy collectionPolicy) {
|
||||||
|
if scope.collections == nil {
|
||||||
|
scope.collections = make(map[string]collectionPolicyEntry)
|
||||||
|
}
|
||||||
|
scope.collections[destination.String()] = collectionPolicyEntry{
|
||||||
|
destination: destination.clone(), policy: cloneCollectionPolicy(policy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scope collectionPolicyScope) at(destination Path) (collectionPolicy, bool) {
|
||||||
|
entry, ok := scope.collections[destination.String()]
|
||||||
|
if !ok {
|
||||||
|
return collectionPolicy{}, false
|
||||||
|
}
|
||||||
|
return cloneCollectionPolicy(entry.policy), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireCollectionPoliciesAtOrBelow clears YAML identity and record-owned state
|
||||||
|
// below a replaced ancestor while retaining independently scoped explicit modes.
|
||||||
|
func (scope *collectionPolicyScope) retireCollectionPoliciesAtOrBelow(ancestor Path) {
|
||||||
|
if scope == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, entry := range scope.collections {
|
||||||
|
if !pathHasPrefix(entry.destination, ancestor) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clearCollectionIdentity(&entry.policy)
|
||||||
|
scope.collections[key] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectCollectionPolicyScope(scope collectionPolicyScope, prefix Path) []projectedCollectionPolicy {
|
||||||
|
keys := make([]string, 0, len(scope.collections))
|
||||||
|
for key := range scope.collections {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var projected []projectedCollectionPolicy
|
||||||
|
for _, key := range keys {
|
||||||
|
entry := scope.collections[key]
|
||||||
|
concrete := appendDestination(prefix, entry.destination)
|
||||||
|
projected = append(projected, projectedCollectionPolicy{path: concrete, policy: cloneCollectionPolicy(entry.policy)})
|
||||||
|
for index, owner := range entry.policy.recordOrder {
|
||||||
|
if recordScope, ok := entry.policy.recordPolicies[owner]; ok {
|
||||||
|
projected = append(projected, projectCollectionPolicyScope(recordScope, concrete.Index(index))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projected
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCollectionPolicyScope(scope collectionPolicyScope) collectionPolicyScope {
|
||||||
|
if scope.collections == nil {
|
||||||
|
return collectionPolicyScope{}
|
||||||
|
}
|
||||||
|
cloned := collectionPolicyScope{collections: make(map[string]collectionPolicyEntry, len(scope.collections))}
|
||||||
|
for key, entry := range scope.collections {
|
||||||
|
cloned.collections[key] = collectionPolicyEntry{
|
||||||
|
destination: entry.destination.clone(), policy: cloneCollectionPolicy(entry.policy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCollectionPolicyScopeForInheritance(scope collectionPolicyScope) collectionPolicyScope {
|
||||||
|
if scope.collections == nil {
|
||||||
|
return collectionPolicyScope{}
|
||||||
|
}
|
||||||
|
cloned := collectionPolicyScope{collections: make(map[string]collectionPolicyEntry, len(scope.collections))}
|
||||||
|
for key, entry := range scope.collections {
|
||||||
|
cloned.collections[key] = collectionPolicyEntry{
|
||||||
|
destination: entry.destination.clone(), policy: cloneCollectionPolicyForInheritance(entry.policy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCollectionPolicy(policy collectionPolicy) collectionPolicy {
|
||||||
|
cloned := policy
|
||||||
|
cloned.identityFields = cloneStrings(policy.identityFields)
|
||||||
|
cloned.identityLocation = cloneLocation(policy.identityLocation)
|
||||||
|
cloned.modeLocation = cloneLocation(policy.modeLocation)
|
||||||
|
if policy.recordOrder != nil {
|
||||||
|
cloned.recordOrder = append([]*collectionRecordOwner(nil), policy.recordOrder...)
|
||||||
|
}
|
||||||
|
if policy.recordPolicies != nil {
|
||||||
|
cloned.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope, len(policy.recordPolicies))
|
||||||
|
for owner, scope := range policy.recordPolicies {
|
||||||
|
cloned.recordPolicies[owner] = cloneCollectionPolicyScope(scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCollectionPolicyForInheritance(policy collectionPolicy) collectionPolicy {
|
||||||
|
cloned := policy
|
||||||
|
cloned.identityFields = cloneStrings(policy.identityFields)
|
||||||
|
cloned.identityLocation = cloneLocation(policy.identityLocation)
|
||||||
|
cloned.modeLocation = cloneLocation(policy.modeLocation)
|
||||||
|
if policy.recordOrder != nil {
|
||||||
|
cloned.recordOrder = make([]*collectionRecordOwner, 0, len(policy.recordOrder))
|
||||||
|
}
|
||||||
|
if policy.recordPolicies != nil {
|
||||||
|
cloned.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope, len(policy.recordPolicies))
|
||||||
|
for _, sourceOwner := range policy.recordOrder {
|
||||||
|
owner := newCollectionRecordPolicy().owner
|
||||||
|
cloned.recordOrder = append(cloned.recordOrder, owner)
|
||||||
|
if sourceScope, ok := policy.recordPolicies[sourceOwner]; ok {
|
||||||
|
cloned.recordPolicies[owner] = cloneCollectionPolicyScopeForInheritance(sourceScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
292
internal/engine/collection_policy_state_test.go
Normal file
292
internal/engine/collection_policy_state_test.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) {
|
||||||
|
lower := collectionPolicy{
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend,
|
||||||
|
explicitModePresent: true,
|
||||||
|
}
|
||||||
|
lower.setRecordPolicy(newCollectionRecordPolicy())
|
||||||
|
current := collectionPolicy{explicitMode: sequenceModeReplace, explicitModePresent: true}
|
||||||
|
retained, diagnostics := collectionPolicyWithoutValue(&lower, current, nil, Keys("prompts"))
|
||||||
|
if len(diagnostics) != 0 || !retained.identityPresent || !reflect.DeepEqual(retained.identityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("no-value replace reset identity: %#v, %#v", retained, diagnostics)
|
||||||
|
}
|
||||||
|
if retained.explicitMode != sequenceModeReplace || !retained.explicitModePresent || len(retained.recordOrder) != 1 {
|
||||||
|
t.Fatalf("no-value policy update lost mode or record ownership: %#v", retained)
|
||||||
|
}
|
||||||
|
retainedRecord, _ := retained.recordPolicyAt(0)
|
||||||
|
lowerRecord, _ := lower.recordPolicyAt(0)
|
||||||
|
if retainedRecord.owner != lowerRecord.owner {
|
||||||
|
t.Fatal("no-value policy update changed record ownership")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyWithoutValueLeavesAbsentAndNonSequenceDestinationsUnconstrained(t *testing.T) {
|
||||||
|
current := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
for _, value := range []*document.Node{nil, parsePolicyNode(t, "value: scalar\n").Mapping[0].Value} {
|
||||||
|
retained, diagnostics := collectionPolicyWithoutValue(nil, current, value, Keys("prompts"))
|
||||||
|
if len(diagnostics) != 0 || !retained.identityPresent || !reflect.DeepEqual(retained.identityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("pending identity at absent/non-sequence destination = %#v, %#v", retained, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyWithoutValueValidatesRetainedSequenceImmediately(t *testing.T) {
|
||||||
|
current := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
valid := parsePolicyNode(t, "items: [{id: one}, {id: two}]\n").Mapping[0].Value
|
||||||
|
retained, diagnostics := collectionPolicyWithoutValue(nil, current, valid, Keys("prompts"))
|
||||||
|
if len(diagnostics) != 0 || !retained.identityPresent {
|
||||||
|
t.Fatalf("valid retained records were rejected: %#v, %#v", retained, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := parsePolicyNode(t, "items: [{name: missing-id}]\n").Mapping[0].Value
|
||||||
|
retained, diagnostics = collectionPolicyWithoutValue(nil, current, invalid, Keys("prompts"))
|
||||||
|
if !reflect.DeepEqual(retained, collectionPolicy{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/prompts/0/id" {
|
||||||
|
t.Fatalf("invalid retained records = %#v, %#v", retained, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyWithoutValueRejectsIdentityChange(t *testing.T) {
|
||||||
|
lower := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
current := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||||
|
retained, diagnostics := collectionPolicyWithoutValue(&lower, current, nil, Keys("prompts"))
|
||||||
|
if !reflect.DeepEqual(retained, collectionPolicy{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("no-value identity change = %#v, %#v", retained, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyLifetimePersistenceAndReplacementTable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current collectionPolicy
|
||||||
|
replaceValue bool
|
||||||
|
wantIdentity []string
|
||||||
|
wantIdentitySet bool
|
||||||
|
wantMode sequenceMode
|
||||||
|
wantRecords int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mode-only replace declaration does not reset retained identity",
|
||||||
|
current: collectionPolicy{
|
||||||
|
explicitMode: sequenceModeReplace, explicitModePresent: true,
|
||||||
|
},
|
||||||
|
wantIdentity: []string{"id"}, wantIdentitySet: true,
|
||||||
|
wantMode: sequenceModeReplace, wantRecords: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whole-sequence replacement resets lower identity",
|
||||||
|
replaceValue: true,
|
||||||
|
wantIdentitySet: false,
|
||||||
|
wantMode: sequenceModeAppend,
|
||||||
|
wantRecords: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replacement can establish a new identity",
|
||||||
|
current: collectionPolicy{identityFields: []string{"name"}, identityPresent: true},
|
||||||
|
replaceValue: true,
|
||||||
|
wantIdentity: []string{"name"},
|
||||||
|
wantIdentitySet: true,
|
||||||
|
wantMode: sequenceModeAppend,
|
||||||
|
wantRecords: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
lower := collectionPolicy{
|
||||||
|
identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
}
|
||||||
|
lower.setRecordPolicy(newCollectionRecordPolicy())
|
||||||
|
var got collectionPolicy
|
||||||
|
if test.replaceValue {
|
||||||
|
got = collectionPolicyForReplacement(&lower, test.current)
|
||||||
|
} else {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
got, diagnostics = collectionPolicyWithoutValue(&lower, test.current, nil, Keys("prompts"))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got.identityPresent != test.wantIdentitySet || !reflect.DeepEqual(got.identityFields, test.wantIdentity) || got.explicitMode != test.wantMode || len(got.recordOrder) != test.wantRecords {
|
||||||
|
t.Fatalf("transition result = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyValueReplacementResetsIdentityButRetainsExplicitMode(t *testing.T) {
|
||||||
|
lower := collectionPolicy{
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend,
|
||||||
|
explicitModePresent: true,
|
||||||
|
}
|
||||||
|
lower.setRecordPolicy(newCollectionRecordPolicy())
|
||||||
|
current := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||||
|
replaced := collectionPolicyForReplacement(&lower, current)
|
||||||
|
if !replaced.identityPresent || !reflect.DeepEqual(replaced.identityFields, []string{"name"}) {
|
||||||
|
t.Fatalf("replacement identity was not reset: %#v", replaced)
|
||||||
|
}
|
||||||
|
if replaced.explicitMode != sequenceModeAppend || !replaced.explicitModePresent || len(replaced.recordOrder) != 0 {
|
||||||
|
t.Fatalf("replacement failed to retain explicit mode or retire lower record policy: %#v", replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared := collectionPolicyForReplacement(&lower, collectionPolicy{})
|
||||||
|
if cleared.identityPresent || len(cleared.identityFields) != 0 || cleared.explicitMode != sequenceModeAppend || !cleared.explicitModePresent {
|
||||||
|
t.Fatalf("replacement without a new key did not clear YAML identity while retaining mode: %#v", cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionRecordRetentionAndReplacementUseOpaqueOwners(t *testing.T) {
|
||||||
|
oldRecord := newCollectionRecordPolicy()
|
||||||
|
oldRecord.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
retained := retainedCollectionRecordPolicy(oldRecord)
|
||||||
|
if retained.owner != oldRecord.owner {
|
||||||
|
t.Fatal("retained record did not keep its owner")
|
||||||
|
}
|
||||||
|
if got, ok := retained.scope.at(Keys("steps")); !ok || !got.identityPresent {
|
||||||
|
t.Fatalf("retained record lost its local policy: %#v, %t", got, ok)
|
||||||
|
}
|
||||||
|
if retained.scope.collections["/steps"].policy.identityFields[0] = "changed"; oldRecord.scope.collections["/steps"].policy.identityFields[0] != "id" {
|
||||||
|
t.Fatal("retained record policy aliases its source")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A whole-record replacement at the same list position, even with equal
|
||||||
|
// authored identity/source metadata, receives new ownership and no old rules.
|
||||||
|
replacement := replacedCollectionRecordPolicy()
|
||||||
|
if replacement.owner == oldRecord.owner || len(replacement.scope.collections) != 0 {
|
||||||
|
t.Fatal("replacement record inherited discarded owner policy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyAncestorRetirementPreservesIndependentMode(t *testing.T) {
|
||||||
|
var scope collectionPolicyScope
|
||||||
|
owner := newCollectionRecordPolicy()
|
||||||
|
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
scope.set(Keys("catalog", "prompts"), collectionPolicyWithRecords(collectionPolicy{
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend,
|
||||||
|
explicitModePresent: true,
|
||||||
|
}, owner))
|
||||||
|
scope.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||||
|
policy, ok := scope.at(Keys("catalog", "prompts"))
|
||||||
|
if !ok || policy.identityPresent || len(policy.recordOrder) != 0 || policy.explicitMode != sequenceModeAppend || !policy.explicitModePresent {
|
||||||
|
t.Fatalf("ancestor retirement = %#v, %t", policy, ok)
|
||||||
|
}
|
||||||
|
reintroduced, diagnostics := collectionPolicyWithoutValue(&policy, collectionPolicy{}, nil, Keys("catalog", "prompts"))
|
||||||
|
if len(diagnostics) != 0 || reintroduced.identityPresent || reintroduced.explicitMode != sequenceModeAppend {
|
||||||
|
t.Fatalf("retired identity was resurrected while mode persisted: %#v, %#v", reintroduced, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEqualOuterIdentitiesHaveDistinctRecordOwners(t *testing.T) {
|
||||||
|
retained := newCollectionRecordPolicy()
|
||||||
|
replacement := replacedCollectionRecordPolicy()
|
||||||
|
if retained.owner == replacement.owner {
|
||||||
|
t.Fatal("replacement with the same outer identity reused the old owner")
|
||||||
|
}
|
||||||
|
if retained.owner == nil || replacement.owner == nil {
|
||||||
|
t.Fatal("record owner token is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyScopesKeepRootAndItemRulesIndependent(t *testing.T) {
|
||||||
|
var root collectionPolicyScope
|
||||||
|
root.set(Keys("steps"), collectionPolicy{identityFields: []string{"root-id"}, identityPresent: true})
|
||||||
|
owner := newCollectionRecordPolicy()
|
||||||
|
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"item-id"}, identityPresent: true})
|
||||||
|
root.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, owner))
|
||||||
|
|
||||||
|
root.retireCollectionPoliciesAtOrBelow(Keys("steps"))
|
||||||
|
rootPolicy, ok := root.at(Keys("steps"))
|
||||||
|
if !ok || rootPolicy.identityPresent {
|
||||||
|
t.Fatalf("root-local policy was not retired: %#v", rootPolicy)
|
||||||
|
}
|
||||||
|
itemPolicy, ok := root.at(Keys("items"))
|
||||||
|
if !ok || len(itemPolicy.recordOrder) != 1 {
|
||||||
|
t.Fatalf("unrelated item owner disappeared: %#v", itemPolicy)
|
||||||
|
}
|
||||||
|
itemRecord, _ := itemPolicy.recordPolicyAt(0)
|
||||||
|
nested, ok := itemRecord.scope.at(Keys("steps"))
|
||||||
|
if !ok || !nested.identityPresent || !reflect.DeepEqual(nested.identityFields, []string{"item-id"}) {
|
||||||
|
t.Fatalf("item-local policy was coupled to root destination: %#v", nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyProjectionUsesConcretePlacement(t *testing.T) {
|
||||||
|
first := newCollectionRecordPolicy()
|
||||||
|
first.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"step"}, identityPresent: true})
|
||||||
|
second := newCollectionRecordPolicy()
|
||||||
|
second.scope.set(Keys("steps"), collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
|
||||||
|
var scope collectionPolicyScope
|
||||||
|
scope.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, first, second))
|
||||||
|
|
||||||
|
projected := projectCollectionPolicyScope(scope, Keys("mounted"))
|
||||||
|
if len(projected) != 3 {
|
||||||
|
t.Fatalf("projected policies = %#v", projected)
|
||||||
|
}
|
||||||
|
want := []string{"/mounted/items", "/mounted/items/0/steps", "/mounted/items/1/steps"}
|
||||||
|
for index, policy := range projected {
|
||||||
|
if policy.path.String() != want[index] {
|
||||||
|
t.Fatalf("projected path[%d] = %q, want %q", index, policy.path.String(), want[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionPolicyInheritanceCopiesOwnersAndState(t *testing.T) {
|
||||||
|
owner := newCollectionRecordPolicy()
|
||||||
|
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
original := collectionPolicyWithRecords(collectionPolicy{}, owner)
|
||||||
|
copy := cloneCollectionPolicyForInheritance(original)
|
||||||
|
copyOwner := copy.recordOrder[0]
|
||||||
|
if copyOwner == owner.owner {
|
||||||
|
t.Fatal("inheritance copy retained the source record owner")
|
||||||
|
}
|
||||||
|
copy.recordPolicies[copyOwner].collections["/steps"].policy.identityFields[0] = "changed"
|
||||||
|
if owner.scope.collections["/steps"].policy.identityFields[0] != "id" {
|
||||||
|
t.Fatal("inheritance copy aliases source policy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcquisitionScopeCloneOwnsCollectionPolicyCopy(t *testing.T) {
|
||||||
|
owner := newCollectionRecordPolicy()
|
||||||
|
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
var scope acquisitionScope
|
||||||
|
scope.collectionPolicies.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, owner))
|
||||||
|
cloned := cloneAcquisitionScope(scope)
|
||||||
|
clonedOwner := cloned.collectionPolicies.collections["/items"].policy.recordOrder[0]
|
||||||
|
cloned.collectionPolicies.collections["/items"].policy.recordPolicies[clonedOwner].collections["/steps"].policy.identityFields[0] = "changed"
|
||||||
|
sourceOwner := scope.collectionPolicies.collections["/items"].policy.recordOrder[0]
|
||||||
|
if scope.collectionPolicies.collections["/items"].policy.recordPolicies[sourceOwner].collections["/steps"].policy.identityFields[0] != "id" {
|
||||||
|
t.Fatal("acquisition scope clone aliases collection policy")
|
||||||
|
}
|
||||||
|
if clonedOwner != owner.owner {
|
||||||
|
t.Fatal("acquisition scope clone changed record ownership")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionPolicyWithRecords(policy collectionPolicy, records ...collectionRecordPolicy) collectionPolicy {
|
||||||
|
for _, record := range records {
|
||||||
|
policy.setRecordPolicy(record)
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePolicyNode(t *testing.T, input string) *document.Node {
|
||||||
|
t.Helper()
|
||||||
|
parsed, diagnostics := parseSource(Bytes("collection-policy-state.yml", []byte(input)), "collection-policy-state", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("parse policy value: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return parsed.document
|
||||||
|
}
|
||||||
189
internal/engine/collection_policy_test.go
Normal file
189
internal/engine/collection_policy_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testing.T) {
|
||||||
|
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: [namespace, id]
|
||||||
|
mode: append
|
||||||
|
- destination: [prompts]
|
||||||
|
key: [id, namespace, id]
|
||||||
|
- destination: [prompts]
|
||||||
|
mode: append
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, nil, nil)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if !resolved.modeDeclarationPresent {
|
||||||
|
t.Fatalf("mode declaration was lost: %#v", resolved)
|
||||||
|
}
|
||||||
|
wantFields := []string{"id", "namespace"}
|
||||||
|
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
|
||||||
|
t.Fatalf("effective identity = %#v / %#v, want %#v", resolved.policy.identityFields, resolved.effectiveIdentityFields, wantFields)
|
||||||
|
}
|
||||||
|
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
|
||||||
|
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "identity fields",
|
||||||
|
body: `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
- destination: [prompts]
|
||||||
|
key: name
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit modes",
|
||||||
|
body: `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
mode: append
|
||||||
|
- destination: [prompts]
|
||||||
|
mode: replace
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
declarations := extractSequenceDeclarationsYAML(t, test.body, rootDirectiveContextSource).sequences
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, nil, nil)
|
||||||
|
if !reflect.DeepEqual(resolved, collectionPolicyResolution{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("resolution/diagnostics = %#v / %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
diagnostic := diagnostics[0]
|
||||||
|
if diagnostic.Path.String() != "/prompts" || diagnostic.Primary == nil || len(diagnostic.Related) != 1 {
|
||||||
|
t.Fatalf("conflict does not identify both declaration sites: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
if diagnostic.Primary.SourceID != "sequence-declarations" || diagnostic.Related[0].SourceID != "sequence-declarations" || diagnostic.Primary.Line == diagnostic.Related[0].Line {
|
||||||
|
t.Fatalf("conflict locations = %#v and %#v", diagnostic.Primary, diagnostic.Related)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicySelectsCurrentThenRetainedExplicitMode(t *testing.T) {
|
||||||
|
lower := &collectionPolicy{
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend,
|
||||||
|
explicitModePresent: true,
|
||||||
|
modeLocation: &Location{SourceID: "lower", Line: 2},
|
||||||
|
}
|
||||||
|
current := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
mode: upsert
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), current, nil, lower)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || resolved.policy.explicitMode != sequenceModeUpsert || !resolved.policy.explicitModePresent {
|
||||||
|
t.Fatalf("current mode did not override retained mode: %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), nil, nil, lower)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeAppend || resolved.policy.explicitMode != sequenceModeAppend || !resolved.policy.explicitModePresent {
|
||||||
|
t.Fatalf("omitted current mode did not retain lower explicit mode: %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(resolved.effectiveIdentityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("retained identity = %#v", resolved.effectiveIdentityFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicyInfersWithoutPersistingMode(t *testing.T) {
|
||||||
|
keyOnly := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), keyOnly, nil, nil)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || resolved.policy.explicitModePresent {
|
||||||
|
t.Fatalf("identity-based mode inference = %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), nil, []string{"id"}, nil)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || resolved.policy.identityPresent || !resolved.applicationIdentityPresent {
|
||||||
|
t.Fatalf("application identity was not kept distinct from YAML policy: %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
noIdentity := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), noIdentity, nil, nil)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeReplace || resolved.policy.explicitModePresent {
|
||||||
|
t.Fatalf("unkeyed mode inference = %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitDefault := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
mode: replace
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), explicitDefault, nil, nil)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeReplace || !resolved.policy.explicitModePresent {
|
||||||
|
t.Fatalf("explicit default mode lost its presence: %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicyChecksKeyOnlyAgainstApplicationConstraint(t *testing.T) {
|
||||||
|
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: [namespace, id]
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, []string{"id", "namespace", "id"}, nil)
|
||||||
|
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || !reflect.DeepEqual(resolved.effectiveIdentityFields, []string{"id", "namespace"}) {
|
||||||
|
t.Fatalf("equivalent key-only declaration was rejected: %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), declarations, []string{"slug"}, nil)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("conflicting key-only declaration = %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Path.String() != "/prompts" || len(diagnostics[0].Related) != 0 {
|
||||||
|
t.Fatalf("application conflict diagnostic = %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicyChecksRetainedIdentityAgainstApplicationConstraint(t *testing.T) {
|
||||||
|
lower := &collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), nil, []string{"name"}, lower)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/prompts" {
|
||||||
|
t.Fatalf("retained/application conflict = %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCollectionPolicyRequiresCurrentIdentityToAgreeWithRetainedIdentity(t *testing.T) {
|
||||||
|
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: name
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
lower := &collectionPolicy{
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
identityPresent: true,
|
||||||
|
identityLocation: &Location{SourceID: "lower", Line: 4},
|
||||||
|
}
|
||||||
|
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, nil, lower)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/prompts" {
|
||||||
|
t.Fatalf("current/retained identity conflict = %#v, %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 || diagnostics[0].Related[0].SourceID != "lower" {
|
||||||
|
t.Fatalf("current/retained conflict locations = %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/engine/collection_retirement.go
Normal file
111
internal/engine/collection_retirement.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
// A true entry replaces the collection itself as well as its descendants;
|
||||||
|
// false records ordinary ancestor replacement without selecting a collection
|
||||||
|
// operation at that exact path.
|
||||||
|
type collectionReplacementPaths map[string]bool
|
||||||
|
|
||||||
|
type lowerCollectionPolicyView struct {
|
||||||
|
scope collectionPolicyScope
|
||||||
|
replacements collectionReplacementPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view lowerCollectionPolicyView) at(path Path) (collectionPolicy, bool) {
|
||||||
|
entry, found := view.scope.collections[path.String()]
|
||||||
|
if !found {
|
||||||
|
return collectionPolicy{}, false
|
||||||
|
}
|
||||||
|
policy := entry.policy
|
||||||
|
if view.replacements.contains(path, true) {
|
||||||
|
clearCollectionIdentity(&policy)
|
||||||
|
}
|
||||||
|
return policy, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
|
||||||
|
for length := 0; length <= len(path.segments); length++ {
|
||||||
|
prefix := Path{segments: path.segments[:length]}
|
||||||
|
inclusive, found := paths[prefix.String()]
|
||||||
|
if found && (length < len(path.segments) || includeExact && inclusive) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowerCollectionPoliciesForClaims makes replacement eligibility independent of
|
||||||
|
// declaration order. It removes only old YAML identity/record state; new pending
|
||||||
|
// declarations and application constraints are reconciled afterward.
|
||||||
|
func lowerCollectionPoliciesForClaims(
|
||||||
|
lower collectionPolicyScope,
|
||||||
|
claims []compositionClaim,
|
||||||
|
declarations map[string][]sequenceDeclarationSpec,
|
||||||
|
application sequencePolicy,
|
||||||
|
) (collectionPolicyScope, collectionReplacementPaths) {
|
||||||
|
replaced := collectionReplacementPathsForClaims(lower, claims, declarations, application)
|
||||||
|
// This materialization is used when inheritance creates an independently
|
||||||
|
// owned local scope. Strict tier resolution uses lowerCollectionPolicyView
|
||||||
|
// instead and keeps only its sparse current-scope result.
|
||||||
|
result := cloneCollectionPolicyScope(lower)
|
||||||
|
for key, entry := range result.collections {
|
||||||
|
if replaced.contains(entry.destination, true) {
|
||||||
|
clearCollectionIdentity(&entry.policy)
|
||||||
|
result.collections[key] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionReplacementPathsForClaims(
|
||||||
|
lower collectionPolicyScope,
|
||||||
|
claims []compositionClaim,
|
||||||
|
declarations map[string][]sequenceDeclarationSpec,
|
||||||
|
application sequencePolicy,
|
||||||
|
) collectionReplacementPaths {
|
||||||
|
byPath := make(map[string]compositionClaim, len(claims))
|
||||||
|
rootPath := ""
|
||||||
|
if len(claims) != 0 {
|
||||||
|
rootPath = claims[0].path.String()
|
||||||
|
}
|
||||||
|
for _, claim := range claims {
|
||||||
|
byPath[claim.path.String()] = claim
|
||||||
|
}
|
||||||
|
replaced := make(collectionReplacementPaths)
|
||||||
|
for key, claim := range byPath {
|
||||||
|
old, hasOld := lower.collections[key]
|
||||||
|
current := declarations[key]
|
||||||
|
if claim.kind == document.Mapping && (key == rootPath || !hasOld && len(current) == 0) {
|
||||||
|
continue // Ordinary mapping combination preserves descendants.
|
||||||
|
}
|
||||||
|
mode := sequenceMode(0)
|
||||||
|
identity := old.policy.identityPresent
|
||||||
|
for _, declaration := range current {
|
||||||
|
identity = identity || declaration.identityPresent
|
||||||
|
if mode == 0 && declaration.modePresent {
|
||||||
|
mode = declaration.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retainedMode := sequenceMode(0)
|
||||||
|
if old.policy.explicitModePresent {
|
||||||
|
retainedMode = old.policy.explicitMode
|
||||||
|
}
|
||||||
|
_, registered := application.identityFieldsAt(claim.path)
|
||||||
|
mode = collectionOperationMode(mode, retainedMode, identity || registered)
|
||||||
|
if mode == sequenceModeReplace {
|
||||||
|
replaced[key] = true
|
||||||
|
} else if claim.kind != document.Mapping && claim.kind != document.Sequence {
|
||||||
|
replaced[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCollectionIdentity(policy *collectionPolicy) {
|
||||||
|
policy.identityFields = nil
|
||||||
|
policy.identityPresent = false
|
||||||
|
policy.identityLocation = nil
|
||||||
|
policy.recordOrder = nil
|
||||||
|
policy.recordPolicies = nil
|
||||||
|
}
|
||||||
350
internal/engine/collection_retirement_test.go
Normal file
350
internal/engine/collection_retirement_test.go
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, value := range []string{"null", "42", "{}", "[{name: new}]"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
lower := "configkit:\n sequences: [{destination: [items], key: id}]\nitems: [{id: old}]\n"
|
||||||
|
main := "configkit:\n sequences: [{destination: [items], key: name, mode: replace}]\nitems: " + value + "\n"
|
||||||
|
loaded, err := testGroupedLoad(loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: Bytes("main", []byte(main))},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte(lower))}}}},
|
||||||
|
})
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("replacement retained obsolete identity: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAncestorRemovalCanEstablishPendingReplacementIdentity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, inheritance := range []bool{false, true} {
|
||||||
|
name := "tiers"
|
||||||
|
if inheritance {
|
||||||
|
name = "inheritance"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var loaded *testResult[map[string]any]
|
||||||
|
var err error
|
||||||
|
if inheritance {
|
||||||
|
loaded, err = testLoad[map[string]any](Bytes("source", []byte(`configkit:
|
||||||
|
sequences:
|
||||||
|
- {destination: [base, group, items], key: id}
|
||||||
|
- {destination: [child, group, items], key: name}
|
||||||
|
base: {group: {items: [{id: old}]}}
|
||||||
|
child:
|
||||||
|
extends: base
|
||||||
|
group: null
|
||||||
|
grandchild:
|
||||||
|
extends: child
|
||||||
|
group: {items: [{name: new}]}
|
||||||
|
`)))
|
||||||
|
} else {
|
||||||
|
loaded, err = testGroupedLoad(loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: Bytes("main", []byte("group: {items: [{name: new}]}\n"))},
|
||||||
|
underlays: []loadGroup{
|
||||||
|
{inputs: []loadInput{{source: Bytes("base", []byte("configkit:\n sequences: [{destination: [group, items], key: id}]\ngroup: {items: [{id: old}]}\n"))}}},
|
||||||
|
{inputs: []loadInput{{source: Bytes("remove", []byte("configkit:\n sequences: [{destination: [group, items], key: name}]\ngroup: null\n"))}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value := loaded.Config
|
||||||
|
if inheritance {
|
||||||
|
value = value["grandchild"].(map[string]any)
|
||||||
|
}
|
||||||
|
items := value["group"].(map[string]any)["items"].([]any)
|
||||||
|
if len(items) != 1 || items[0].(map[string]any)["name"] != "new" {
|
||||||
|
t.Fatalf("reintroduced collection = %#v", items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceAncestorRetirementDoesNotResurrectOldIdentity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := testLoad[map[string]any](Bytes("source", []byte(`configkit:
|
||||||
|
sequences: [{destination: [base, group, items], key: id}]
|
||||||
|
base: {group: {items: [{id: old}]}}
|
||||||
|
child:
|
||||||
|
extends: base
|
||||||
|
group: null
|
||||||
|
grandchild:
|
||||||
|
extends: child
|
||||||
|
group: {items: [{name: new}]}
|
||||||
|
`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, withValue := range []bool{false, true} {
|
||||||
|
main := "configkit:\n sequences: [{destination: [items], key: name, mode: replace}]\n"
|
||||||
|
if withValue {
|
||||||
|
main += "items: null\n"
|
||||||
|
}
|
||||||
|
definition := Definition[map[string]any]{}
|
||||||
|
if withValue {
|
||||||
|
definition.sequenceIdentityRegistrations = []sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
_, err := testGroupedLoad(loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: Bytes("main", []byte(main))}, definition: definition,
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("configkit:\n sequences: [{destination: [items], key: id}]\nitems: [{id: old}]\n"))}}}},
|
||||||
|
})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("retirement bypassed identity agreement: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemblyPolicyRetirementUsesSemanticPathSegments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var policies collectionPolicyScope
|
||||||
|
slashPath := Keys("branch/name", "items")
|
||||||
|
separateKeysPath := Keys("branch", "name", "items")
|
||||||
|
numericKeyPath := Keys("items", "0", "named")
|
||||||
|
sequenceIndexPath := Keys("items").Index(0).Key("indexed")
|
||||||
|
policies.set(slashPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
policies.set(separateKeysPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
policies.set(numericKeyPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
policies.set(sequenceIndexPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
|
||||||
|
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
|
||||||
|
assembly.retireCollectionPoliciesAtOrBelow(Keys("branch/name"))
|
||||||
|
assertRetirement := func(path Path, identityPresent bool) {
|
||||||
|
t.Helper()
|
||||||
|
policy, found := assembly.current.collectionPolicies.at(path)
|
||||||
|
if !found || policy.identityPresent != identityPresent {
|
||||||
|
t.Fatalf("policy at %s = %#v (found %t), want identity present %t", path, policy, found, identityPresent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertRetirement(slashPath, false)
|
||||||
|
assertRetirement(separateKeysPath, true)
|
||||||
|
|
||||||
|
assembly.retireCollectionPoliciesAtOrBelow(Keys("items", "0"))
|
||||||
|
assertRetirement(numericKeyPath, false)
|
||||||
|
assertRetirement(sequenceIndexPath, true)
|
||||||
|
assembly.retireCollectionPoliciesAtOrBelow(Keys("items").Index(0))
|
||||||
|
assertRetirement(sequenceIndexPath, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemblyPolicyRetirementPrunesAndReindexesAroundModeOnlyEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
identityPath := Keys("catalog", "items")
|
||||||
|
modePath := Keys("catalog", "settings")
|
||||||
|
var policies collectionPolicyScope
|
||||||
|
policies.set(identityPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
policies.set(modePath, collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
|
||||||
|
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
|
||||||
|
|
||||||
|
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||||
|
retired, found := assembly.current.collectionPolicies.at(identityPath)
|
||||||
|
if !found || retired.identityPresent || !assembly.collectionPolicyRetirement.empty() {
|
||||||
|
t.Fatalf("first retirement left identity or stale index: %#v, index %#v", retired, assembly.collectionPolicyRetirement)
|
||||||
|
}
|
||||||
|
mode, found := assembly.current.collectionPolicies.at(modePath)
|
||||||
|
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
|
||||||
|
t.Fatalf("mode-only policy changed during retirement: %#v, found %t", mode, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||||
|
newPath := Keys("catalog", "items", "nested")
|
||||||
|
assembly.setCollectionPolicy(newPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
|
||||||
|
if assembly.collectionPolicyRetirement.empty() {
|
||||||
|
t.Fatal("new identity-bearing policy was not reindexed")
|
||||||
|
}
|
||||||
|
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||||
|
newPolicy, found := assembly.current.collectionPolicies.at(newPath)
|
||||||
|
if !found || newPolicy.identityPresent || !assembly.collectionPolicyRetirement.empty() {
|
||||||
|
t.Fatalf("repeated ancestor retirement left new identity or stale index: %#v, index %#v", newPolicy, assembly.collectionPolicyRetirement)
|
||||||
|
}
|
||||||
|
mode, found = assembly.current.collectionPolicies.at(modePath)
|
||||||
|
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
|
||||||
|
t.Fatalf("repeated retirement changed retained mode: %#v, found %t", mode, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisjointScalarReplacementsRetainIndexedCollectionPolicies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const policyCount = 128
|
||||||
|
const replacementCount = 32
|
||||||
|
|
||||||
|
var policies collectionPolicyScope
|
||||||
|
collectionEntries := make([]document.Entry, 0, policyCount)
|
||||||
|
for index := 0; index < policyCount; index++ {
|
||||||
|
key := fmt.Sprintf("collection-%03d", index)
|
||||||
|
path := Keys("collections", key)
|
||||||
|
policies.set(path, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
collectionEntries = append(collectionEntries, document.Entry{
|
||||||
|
Key: key, Value: &document.Node{Kind: document.Sequence},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
baseScalars := make([]document.Entry, 0, replacementCount)
|
||||||
|
incomingScalars := make([]document.Entry, 0, replacementCount)
|
||||||
|
for index := 0; index < replacementCount; index++ {
|
||||||
|
key := fmt.Sprintf("scalar-%03d", index)
|
||||||
|
baseScalars = append(baseScalars, document.Entry{
|
||||||
|
Key: key, Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "previous", Value: &document.Node{Kind: document.Boolean, Text: "true"},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
incomingScalars = append(incomingScalars, document.Entry{
|
||||||
|
Key: key, Value: &document.Node{Kind: document.String, Text: "replaced"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
base := &composedDocument{
|
||||||
|
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||||
|
{Key: "collections", Value: &document.Node{Kind: document.Mapping, Mapping: collectionEntries}},
|
||||||
|
{Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: baseScalars}},
|
||||||
|
}},
|
||||||
|
collectionPolicies: policies,
|
||||||
|
origins: make(map[string]Origin),
|
||||||
|
}
|
||||||
|
incoming := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: incomingScalars},
|
||||||
|
}}}
|
||||||
|
assembly := newAssemblyAccumulator(base)
|
||||||
|
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||||
|
incoming, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, nil,
|
||||||
|
); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("apply disjoint scalar replacements: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
for index := 0; index < policyCount; index++ {
|
||||||
|
path := Keys("collections", fmt.Sprintf("collection-%03d", index))
|
||||||
|
policy, found := assembly.current.collectionPolicies.at(path)
|
||||||
|
if !found || !policy.identityPresent || len(policy.identityFields) != 1 || policy.identityFields[0] != "id" {
|
||||||
|
t.Fatalf("disjoint replacement changed policy %s: %#v, found %t", path, policy, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := 0; index < replacementCount; index++ {
|
||||||
|
path := Keys("scalars", fmt.Sprintf("scalar-%03d", index))
|
||||||
|
value, found := documentAtPath(assembly.current.root, path)
|
||||||
|
if !found || value.Kind != document.String || value.Text != "replaced" {
|
||||||
|
t.Fatalf("scalar replacement at %s = %#v, found %t", path, value, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisjointTierOverlaysRetainNestedCollectionPolicies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nestedOwner := newCollectionRecordPolicy()
|
||||||
|
nestedOwner.scope.set(Keys("prompts"), collectionPolicy{
|
||||||
|
identityFields: []string{"name"}, identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
})
|
||||||
|
itemsPolicy := collectionPolicyWithRecords(collectionPolicy{
|
||||||
|
identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
}, nestedOwner)
|
||||||
|
var policies collectionPolicyScope
|
||||||
|
policies.set(Keys("items"), itemsPolicy)
|
||||||
|
policies.set(Keys("keyOnly"), collectionPolicy{
|
||||||
|
identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
})
|
||||||
|
policies.set(Keys("modeOnly"), collectionPolicy{
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
base := &composedDocument{
|
||||||
|
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||||
|
{Key: "items", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
|
||||||
|
Kind: document.Mapping,
|
||||||
|
Mapping: []document.Entry{
|
||||||
|
{Key: "id", Value: &document.Node{Kind: document.String, Text: "one"}},
|
||||||
|
{Key: "prompts", Value: &document.Node{Kind: document.Sequence}},
|
||||||
|
},
|
||||||
|
}}}},
|
||||||
|
{Key: "keyOnly", Value: &document.Node{Kind: document.Sequence}},
|
||||||
|
{Key: "modeOnly", Value: &document.Node{Kind: document.Sequence}},
|
||||||
|
}},
|
||||||
|
collectionPolicies: policies,
|
||||||
|
origins: make(map[string]Origin),
|
||||||
|
}
|
||||||
|
assembly := newAssemblyAccumulator(base)
|
||||||
|
initialItems, found := assembly.current.collectionPolicies.at(Keys("items"))
|
||||||
|
if !found {
|
||||||
|
t.Fatal("initial item policy is missing")
|
||||||
|
}
|
||||||
|
initialRecord, found := initialItems.recordPolicyAt(0)
|
||||||
|
if !found {
|
||||||
|
t.Fatal("initial nested record owner is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, key := range []string{"firstTier", "secondTier", "thirdTier"} {
|
||||||
|
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: key, Value: &document.Node{Kind: document.Boolean, Text: "true"},
|
||||||
|
}}}
|
||||||
|
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||||
|
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
|
||||||
|
); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("apply disjoint tier %d: %#v", index, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
retainedItems, found := assembly.current.collectionPolicies.at(Keys("items"))
|
||||||
|
if !found || !retainedItems.identityPresent || len(retainedItems.identityFields) != 1 || retainedItems.identityFields[0] != "id" ||
|
||||||
|
!retainedItems.explicitModePresent || retainedItems.explicitMode != sequenceModeAppend {
|
||||||
|
t.Fatalf("tier %d changed retained item policy: %#v, found %t", index, retainedItems, found)
|
||||||
|
}
|
||||||
|
retainedRecord, found := retainedItems.recordPolicyAt(0)
|
||||||
|
if !found || retainedRecord.owner != initialRecord.owner {
|
||||||
|
t.Fatalf("tier %d replaced the nested record owner: %#v, found %t", index, retainedRecord, found)
|
||||||
|
}
|
||||||
|
nested, found := retainedRecord.scope.at(Keys("prompts"))
|
||||||
|
if !found || !nested.identityPresent || len(nested.identityFields) != 1 || nested.identityFields[0] != "name" ||
|
||||||
|
!nested.explicitModePresent || nested.explicitMode != sequenceModeAppend {
|
||||||
|
t.Fatalf("tier %d changed nested collection policy: %#v, found %t", index, nested, found)
|
||||||
|
}
|
||||||
|
keyOnly, found := assembly.current.collectionPolicies.at(Keys("keyOnly"))
|
||||||
|
if !found || !keyOnly.identityPresent || keyOnly.explicitModePresent {
|
||||||
|
t.Fatalf("tier %d changed key-only policy: %#v, found %t", index, keyOnly, found)
|
||||||
|
}
|
||||||
|
modeOnly, found := assembly.current.collectionPolicies.at(Keys("modeOnly"))
|
||||||
|
if !found || modeOnly.identityPresent || !modeOnly.explicitModePresent || modeOnly.explicitMode != sequenceModeAppend {
|
||||||
|
t.Fatalf("tier %d changed mode-only policy: %#v, found %t", index, modeOnly, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayNonSequenceClaimAtRetainedCollectionStillFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var policies collectionPolicyScope
|
||||||
|
policies.set(Keys("items"), collectionPolicy{
|
||||||
|
identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
})
|
||||||
|
base := &composedDocument{
|
||||||
|
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "items", Value: &document.Node{Kind: document.Sequence},
|
||||||
|
}}},
|
||||||
|
collectionPolicies: policies,
|
||||||
|
origins: make(map[string]Origin),
|
||||||
|
}
|
||||||
|
assembly := newAssemblyAccumulator(base)
|
||||||
|
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "items", Value: &document.Node{Kind: document.String, Text: "not a sequence"},
|
||||||
|
}}}
|
||||||
|
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||||
|
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
|
||||||
|
); len(diagnostics) == 0 {
|
||||||
|
t.Fatal("non-sequence overlay at an append collection unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import (
|
||||||
|
"maps"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
// composedDocument is the private handoff between source assembly and later
|
// composedDocument is the private handoff between source assembly and later
|
||||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||||
@@ -8,6 +12,8 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|||||||
type composedDocument struct {
|
type composedDocument struct {
|
||||||
root *document.Node
|
root *document.Node
|
||||||
acquisition []acquisitionScope
|
acquisition []acquisitionScope
|
||||||
|
importGraph importInclusionGraph
|
||||||
|
collectionPolicies collectionPolicyScope
|
||||||
claims []compositionClaim
|
claims []compositionClaim
|
||||||
sequenceRanges []sequenceRange
|
sequenceRanges []sequenceRange
|
||||||
origins map[string]Origin
|
origins map[string]Origin
|
||||||
@@ -22,6 +28,7 @@ type sequenceHistoryMode uint8
|
|||||||
const (
|
const (
|
||||||
sequenceHistoryReplace sequenceHistoryMode = iota + 1
|
sequenceHistoryReplace sequenceHistoryMode = iota + 1
|
||||||
sequenceHistoryAppend
|
sequenceHistoryAppend
|
||||||
|
sequenceHistoryMapped
|
||||||
)
|
)
|
||||||
|
|
||||||
type sequenceHistoryContribution struct {
|
type sequenceHistoryContribution struct {
|
||||||
@@ -29,6 +36,8 @@ type sequenceHistoryContribution struct {
|
|||||||
mode sequenceHistoryMode
|
mode sequenceHistoryMode
|
||||||
offset int
|
offset int
|
||||||
length int
|
length int
|
||||||
|
items []sequenceItemProjection
|
||||||
|
replacements []sequenceItemReplacement
|
||||||
}
|
}
|
||||||
|
|
||||||
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
||||||
@@ -36,10 +45,18 @@ type sequenceHistoryContribution struct {
|
|||||||
type assemblyAccumulator struct {
|
type assemblyAccumulator struct {
|
||||||
current *composedDocument
|
current *composedDocument
|
||||||
index mappingIndex
|
index mappingIndex
|
||||||
|
collectionPolicyRetirement *collectionPolicyRetirementIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
||||||
return &assemblyAccumulator{current: owned}
|
policies := collectionPolicyScope{}
|
||||||
|
if owned != nil {
|
||||||
|
policies = owned.collectionPolicies
|
||||||
|
}
|
||||||
|
return &assemblyAccumulator{
|
||||||
|
current: owned,
|
||||||
|
collectionPolicyRetirement: newCollectionPolicyRetirementIndex(policies),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||||
@@ -51,6 +68,8 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
|
|||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||||
|
importGraph: base.importGraph.clone(),
|
||||||
|
collectionPolicies: cloneCollectionPolicyScope(base.collectionPolicies),
|
||||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||||
applications: cloneHistoryApplications(base.applications),
|
applications: cloneHistoryApplications(base.applications),
|
||||||
@@ -78,6 +97,53 @@ type sequenceRange struct {
|
|||||||
path Path
|
path Path
|
||||||
start int
|
start int
|
||||||
end int
|
end int
|
||||||
|
items []sequenceItemProjection
|
||||||
|
projection sequenceItemProjectionIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
|
||||||
|
if assigned.projection.outputByInput != nil {
|
||||||
|
return assigned.projection.outputForInput(inputIndex)
|
||||||
|
}
|
||||||
|
if assigned.items != nil {
|
||||||
|
output, found := 0, false
|
||||||
|
for _, item := range assigned.items {
|
||||||
|
if item.inputIndex == inputIndex && inputIndex >= 0 && item.outputIndex >= 0 {
|
||||||
|
output, found = item.outputIndex, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output, found
|
||||||
|
}
|
||||||
|
if inputIndex < 0 || inputIndex >= assigned.end-assigned.start {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return assigned.start + inputIndex, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
||||||
|
if assigned.projection.inputByOutput != nil {
|
||||||
|
return assigned.projection.inputForOutput(outputIndex)
|
||||||
|
}
|
||||||
|
if assigned.items != nil {
|
||||||
|
input, found := 0, false
|
||||||
|
for _, item := range assigned.items {
|
||||||
|
if item.outputIndex == outputIndex && outputIndex >= 0 && item.inputIndex >= 0 {
|
||||||
|
input, found = item.inputIndex, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input, found
|
||||||
|
}
|
||||||
|
if outputIndex < assigned.start || outputIndex >= assigned.end {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return outputIndex - assigned.start, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexSequenceRange(assigned sequenceRange) sequenceRange {
|
||||||
|
if assigned.items != nil && assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
||||||
|
assigned.projection = newSequenceItemProjectionIndex(assigned.items)
|
||||||
|
}
|
||||||
|
return assigned
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeDocuments recursively joins disjoint mapping entries. Source order
|
// composeDocuments recursively joins disjoint mapping entries. Source order
|
||||||
@@ -91,6 +157,17 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
|||||||
// sequence rules when policy contains registrations. The ordinary adapter above
|
// sequence rules when policy contains registrations. The ordinary adapter above
|
||||||
// deliberately preserves the existing unregistered behavior for current callers.
|
// deliberately preserves the existing unregistered behavior for current callers.
|
||||||
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||||
|
return composeDocumentsWithCollectionPolicy(policy, collectionPolicyScope{}, nil, inputs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeDocumentsWithCollectionPolicy resolves collection declarations and
|
||||||
|
// eligible lower policy before strictly composing completed peer documents.
|
||||||
|
func composeDocumentsWithCollectionPolicy(
|
||||||
|
policy sequencePolicy,
|
||||||
|
lower collectionPolicyScope,
|
||||||
|
declarations []sequenceDeclarationSpec,
|
||||||
|
inputs ...*document.Node,
|
||||||
|
) (*composedDocument, []Diagnostic) {
|
||||||
if len(inputs) == 0 {
|
if len(inputs) == 0 {
|
||||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
||||||
}
|
}
|
||||||
@@ -108,6 +185,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
|||||||
collected.append(input, Path{}, sourceIndex)
|
collected.append(input, Path{}, sourceIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
identityFieldsByPath, collectionPolicies, diagnostics := resolveStrictCollectionPolicies(
|
||||||
|
collected.ordered, policy, lower, declarations,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
checked := make(map[string]struct{}, len(collected.byPath))
|
checked := make(map[string]struct{}, len(collected.byPath))
|
||||||
for _, claim := range collected.ordered {
|
for _, claim := range collected.ordered {
|
||||||
pointer := claim.path.String()
|
pointer := claim.path.String()
|
||||||
@@ -116,13 +200,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
|||||||
}
|
}
|
||||||
checked[pointer] = struct{}{}
|
checked[pointer] = struct{}{}
|
||||||
claims := collected.byPath[pointer]
|
claims := collected.byPath[pointer]
|
||||||
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
|
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && len(identityFieldsByPath[pointer]) != 0) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
||||||
}
|
}
|
||||||
|
|
||||||
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy)
|
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, identityFieldsByPath)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, diagnostics
|
return nil, diagnostics
|
||||||
}
|
}
|
||||||
@@ -130,7 +214,7 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
|||||||
root := document.Clone(inputs[0])
|
root := document.Clone(inputs[0])
|
||||||
index := new(mappingIndex)
|
index := new(mappingIndex)
|
||||||
for sourceIndex, input := range inputs[1:] {
|
for sourceIndex, input := range inputs[1:] {
|
||||||
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
|
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, identityFieldsByPath, validated)
|
||||||
}
|
}
|
||||||
applications := make([]historyApplication, 0, len(inputs))
|
applications := make([]historyApplication, 0, len(inputs))
|
||||||
seenSequences := make(map[string]bool)
|
seenSequences := make(map[string]bool)
|
||||||
@@ -158,6 +242,7 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
|||||||
completeCompositionOrigins(root, Path{}, origins)
|
completeCompositionOrigins(root, Path{}, origins)
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: root,
|
root: root,
|
||||||
|
collectionPolicies: collectionPolicies,
|
||||||
claims: cloneCompositionClaims(collected.ordered),
|
claims: cloneCompositionClaims(collected.ordered),
|
||||||
sequenceRanges: cloneSequenceRanges(ranges),
|
sequenceRanges: cloneSequenceRanges(ranges),
|
||||||
origins: origins,
|
origins: origins,
|
||||||
@@ -217,71 +302,121 @@ type validatedSequence struct {
|
|||||||
items []*document.Node
|
items []*document.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
|
func resolveStrictCollectionPolicies(
|
||||||
|
claims []compositionClaim,
|
||||||
|
applicationPolicy sequencePolicy,
|
||||||
|
lower collectionPolicyScope,
|
||||||
|
declarations []sequenceDeclarationSpec,
|
||||||
|
) (map[string][]string, collectionPolicyScope, []Diagnostic) {
|
||||||
|
paths := make([]Path, 0, len(claims)+len(declarations))
|
||||||
|
seen := make(map[string]bool, len(claims)+len(declarations))
|
||||||
|
valuePresent := make(map[string]bool)
|
||||||
|
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
|
||||||
|
for _, claim := range claims {
|
||||||
|
key := claim.path.String()
|
||||||
|
valuePresent[key] = true
|
||||||
|
_, hasLower := lower.collections[key]
|
||||||
|
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
paths = append(paths, claim.path.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, declaration := range declarations {
|
||||||
|
key := declaration.destination.String()
|
||||||
|
declarationsByPath[key] = append(declarationsByPath[key], declaration)
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
paths = append(paths, declaration.destination.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identityFieldsByPath := make(map[string][]string)
|
||||||
|
replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||||
|
lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
|
||||||
|
var collectionPolicies collectionPolicyScope
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, path := range paths {
|
||||||
|
key := path.String()
|
||||||
|
currentDeclarations := declarationsByPath[key]
|
||||||
|
|
||||||
|
lowerPolicyValue, hasLower := lowerView.at(path)
|
||||||
|
var lowerPolicy *collectionPolicy
|
||||||
|
if hasLower {
|
||||||
|
lowerPolicy = &lowerPolicyValue
|
||||||
|
}
|
||||||
|
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
|
||||||
|
|
||||||
|
resolution, found := resolveCollectionPolicy(path, currentDeclarations, applicationIdentity, lowerPolicy)
|
||||||
|
if len(found) != 0 {
|
||||||
|
diagnostics = append(diagnostics, found...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(resolution.effectiveIdentityFields) != 0 {
|
||||||
|
identityFieldsByPath[key] = cloneStrings(resolution.effectiveIdentityFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
policyForPath := resolution.policy
|
||||||
|
if hasLower && !valuePresent[key] {
|
||||||
|
// A declaration without a new value changes policy metadata but
|
||||||
|
// leaves retained record ownership untouched.
|
||||||
|
policyForPath = cloneCollectionPolicy(lowerPolicyValue)
|
||||||
|
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
|
||||||
|
policyForPath.identityPresent = resolution.policy.identityPresent
|
||||||
|
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
|
||||||
|
policyForPath.explicitMode = resolution.policy.explicitMode
|
||||||
|
policyForPath.explicitModePresent = resolution.policy.explicitModePresent
|
||||||
|
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
|
||||||
|
}
|
||||||
|
if collectionPolicies.collections == nil {
|
||||||
|
collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
|
||||||
|
}
|
||||||
|
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
|
||||||
|
}
|
||||||
|
return identityFieldsByPath, collectionPolicies, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRegisteredSequences(claims []compositionClaim, identityFieldsByPath map[string][]string) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
|
||||||
itemsByPath := make(map[string]map[int]validatedSequence)
|
itemsByPath := make(map[string]map[int]validatedSequence)
|
||||||
identitiesByPath := make(map[string]map[string]Location)
|
identitiesByPath := make(map[string]map[string]document.Location)
|
||||||
lengthsByPath := make(map[string]int)
|
lengthsByPath := make(map[string]int)
|
||||||
var ranges []sequenceRange
|
var ranges []sequenceRange
|
||||||
for _, claim := range claims {
|
for _, claim := range claims {
|
||||||
if claim.kind != document.Sequence {
|
if claim.kind != document.Sequence {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
identityField, registered := policy.identityFields[claim.path.String()]
|
identityFields := identityFieldsByPath[claim.path.String()]
|
||||||
if !registered {
|
if len(identityFields) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
node := claim.node
|
node := claim.node
|
||||||
pathKey := claim.path.String()
|
pathKey := claim.path.String()
|
||||||
identities := identitiesByPath[pathKey]
|
identities := identitiesByPath[pathKey]
|
||||||
if identities == nil {
|
if identities == nil {
|
||||||
identities = make(map[string]Location, len(node.Sequence))
|
identities = make(map[string]document.Location, len(node.Sequence))
|
||||||
identitiesByPath[pathKey] = identities
|
identitiesByPath[pathKey] = identities
|
||||||
}
|
}
|
||||||
start := lengthsByPath[pathKey]
|
start := lengthsByPath[pathKey]
|
||||||
contribution := validatedSequence{items: node.Sequence}
|
contribution := validatedSequence{items: node.Sequence}
|
||||||
for itemIndex, item := range node.Sequence {
|
records, diagnostics := validateNormalizedSequenceIdentityFrom(node, claim.path, identityFields, sequenceIdentityDiagnosticCodes{
|
||||||
itemPath := claim.path.Index(start + itemIndex)
|
invalid: CodeComposeInvalid, conflict: CodeComposeConflict,
|
||||||
if item == nil || item.Kind != document.Mapping {
|
}, start, sequenceStructuralRecord)
|
||||||
location := (*Location)(nil)
|
if len(diagnostics) != 0 {
|
||||||
if item != nil {
|
return nil, nil, diagnostics
|
||||||
converted := publicLocation(item.Location)
|
|
||||||
location = &converted
|
|
||||||
}
|
}
|
||||||
return nil, nil, []Diagnostic{{
|
byIndex := make([]sequenceIdentityRecord, len(node.Sequence))
|
||||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
for _, record := range records {
|
||||||
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
|
byIndex[record.index] = record
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
fieldIndex := documentEntryIndex(item.Mapping, identityField)
|
for _, record := range byIndex {
|
||||||
if fieldIndex < 0 {
|
if first, duplicate := identities[record.tuple]; duplicate {
|
||||||
location := publicLocation(item.Location)
|
current := publicLocation(record.location)
|
||||||
return nil, nil, []Diagnostic{{
|
|
||||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
||||||
Path: itemPath, Message: "registered sequence item is missing its identity field",
|
|
||||||
Primary: &location,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
field := item.Mapping[fieldIndex].Value
|
|
||||||
if field == nil || field.Kind != document.String {
|
|
||||||
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
|
|
||||||
if field != nil {
|
|
||||||
location = publicLocation(field.Location)
|
|
||||||
}
|
|
||||||
return nil, nil, []Diagnostic{{
|
|
||||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
||||||
Path: itemPath.Key(identityField), Message: "registered sequence identity must be a string",
|
|
||||||
Primary: &location,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
if first, duplicate := identities[field.Text]; duplicate {
|
|
||||||
current := publicLocation(field.Location)
|
|
||||||
return nil, nil, []Diagnostic{{
|
return nil, nil, []Diagnostic{{
|
||||||
Code: CodeComposeConflict, Severity: SeverityError,
|
Code: CodeComposeConflict, Severity: SeverityError,
|
||||||
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
|
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
|
||||||
Primary: &first, Related: []Location{current},
|
Primary: ptrLocation(publicLocation(first)), Related: []Location{current},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
identities[field.Text] = publicLocation(field.Location)
|
identities[record.tuple] = record.location
|
||||||
}
|
}
|
||||||
if itemsByPath[pathKey] == nil {
|
if itemsByPath[pathKey] == nil {
|
||||||
itemsByPath[pathKey] = make(map[int]validatedSequence)
|
itemsByPath[pathKey] = make(map[int]validatedSequence)
|
||||||
@@ -295,7 +430,7 @@ func validateRegisteredSequences(claims []compositionClaim, policy sequencePolic
|
|||||||
return itemsByPath, ranges, nil
|
return itemsByPath, ranges, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
|
||||||
for _, incomingEntry := range incoming.Mapping {
|
for _, incomingEntry := range incoming.Mapping {
|
||||||
entryPath := path.Key(incomingEntry.Key)
|
entryPath := path.Key(incomingEntry.Key)
|
||||||
position := index.find(target, incomingEntry.Key)
|
position := index.find(target, incomingEntry.Key)
|
||||||
@@ -306,7 +441,7 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
current := target.Mapping[position].Value
|
current := target.Mapping[position].Value
|
||||||
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
|
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && len(identityFieldsByPath[entryPath.String()]) != 0 {
|
||||||
contribution := validated[entryPath.String()][sourceIndex]
|
contribution := validated[entryPath.String()][sourceIndex]
|
||||||
if contribution.items != nil {
|
if contribution.items != nil {
|
||||||
for _, item := range contribution.items {
|
for _, item := range contribution.items {
|
||||||
@@ -315,20 +450,15 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
|
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, identityFieldsByPath, validated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
|
||||||
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
|
mergeComposedMappings(target, incoming, path, index, sourceIndex, identityFieldsByPath, validated)
|
||||||
}
|
|
||||||
|
|
||||||
func (p sequencePolicy) registered(path Path) bool {
|
|
||||||
_, ok := p.identityFields[path.String()]
|
|
||||||
return ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
||||||
@@ -336,6 +466,11 @@ func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
|||||||
for i, assigned := range ranges {
|
for i, assigned := range ranges {
|
||||||
cloned[i] = assigned
|
cloned[i] = assigned
|
||||||
cloned[i].path = assigned.path.clone()
|
cloned[i].path = assigned.path.clone()
|
||||||
|
if assigned.items != nil {
|
||||||
|
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
|
||||||
|
}
|
||||||
|
cloned[i].projection.outputByInput = maps.Clone(assigned.projection.outputByInput)
|
||||||
|
cloned[i].projection.inputByOutput = maps.Clone(assigned.projection.inputByOutput)
|
||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
@@ -349,21 +484,6 @@ func allMappingClaims(claims []compositionClaim) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
|
|
||||||
for _, incomingEntry := range incoming.Mapping {
|
|
||||||
position := index.find(target, incomingEntry.Key)
|
|
||||||
if position < 0 {
|
|
||||||
cloned := incomingEntry
|
|
||||||
cloned.Value = document.Clone(incomingEntry.Value)
|
|
||||||
index.append(target, cloned)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Claim validation guarantees that the only remaining overlap is a
|
|
||||||
// mapping pair. Empty mappings naturally contribute no child entries.
|
|
||||||
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentEntryIndex(entries []document.Entry, key string) int {
|
func documentEntryIndex(entries []document.Entry, key string) int {
|
||||||
for index := range entries {
|
for index := range entries {
|
||||||
if entries[index].Key == key {
|
if entries[index].Key == key {
|
||||||
@@ -375,12 +495,28 @@ func documentEntryIndex(entries []document.Entry, key string) int {
|
|||||||
|
|
||||||
func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
|
func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
|
||||||
origins := make(map[string]Origin)
|
origins := make(map[string]Origin)
|
||||||
rangeBySourcePath := make(map[sequenceRangeKey]int, len(ranges))
|
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
|
||||||
for index, assigned := range ranges {
|
for _, assigned := range ranges {
|
||||||
rangeBySourcePath[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = index
|
contribution := sequenceHistoryContribution{
|
||||||
|
path: assigned.path.clone(), mode: sequenceHistoryAppend,
|
||||||
|
offset: assigned.start, length: assigned.end - assigned.start,
|
||||||
|
}
|
||||||
|
if assigned.items != nil {
|
||||||
|
contribution.mode = sequenceHistoryMapped
|
||||||
|
contribution.items = assigned.items
|
||||||
|
}
|
||||||
|
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], contribution)
|
||||||
}
|
}
|
||||||
for sourceIndex, input := range inputs {
|
for sourceIndex, input := range inputs {
|
||||||
collectProjectedCompositionOrigins(input, Path{}, Path{}, sourceIndex, ranges, rangeBySourcePath, origins)
|
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}
|
||||||
|
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||||
|
walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
|
||||||
|
pointer := effective.String()
|
||||||
|
origin := origins[pointer]
|
||||||
|
origin.Layer = LayerBase
|
||||||
|
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
||||||
|
origins[pointer] = origin
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return origins
|
return origins
|
||||||
}
|
}
|
||||||
@@ -398,39 +534,12 @@ func indexSequenceRanges(ranges []sequenceRange) sequenceRangeIndex {
|
|||||||
}
|
}
|
||||||
index := make(sequenceRangeIndex, len(ranges))
|
index := make(sequenceRangeIndex, len(ranges))
|
||||||
for _, assigned := range ranges {
|
for _, assigned := range ranges {
|
||||||
|
assigned = indexSequenceRange(assigned)
|
||||||
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
|
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep local paths for matching Stage 2 ranges and effective paths for emitted
|
|
||||||
// evidence. The paths are transformed segment by segment; pointer strings are
|
|
||||||
// only used as map keys after projection is complete.
|
|
||||||
func collectProjectedCompositionOrigins(node *document.Node, localPath, effectivePath Path, sourceIndex int, ranges []sequenceRange, rangeBySourcePath map[sequenceRangeKey]int, origins map[string]Origin) {
|
|
||||||
pointer := effectivePath.String()
|
|
||||||
origin := origins[pointer]
|
|
||||||
origin.Layer = LayerBase
|
|
||||||
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
|
||||||
origins[pointer] = origin
|
|
||||||
|
|
||||||
switch node.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
collectProjectedCompositionOrigins(entry.Value, localPath.Key(entry.Key), effectivePath.Key(entry.Key), sourceIndex, ranges, rangeBySourcePath, origins)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
start := 0
|
|
||||||
if rangeIndex, ok := rangeBySourcePath[sequenceRangeKey{sourceIndex: sourceIndex, path: localPath.String()}]; ok {
|
|
||||||
start = ranges[rangeIndex].start
|
|
||||||
}
|
|
||||||
for index, child := range node.Sequence {
|
|
||||||
localChild := localPath.Index(index)
|
|
||||||
effectiveChild := effectivePath.Index(start + index)
|
|
||||||
collectProjectedCompositionOrigins(child, localChild, effectiveChild, sourceIndex, ranges, rangeBySourcePath, origins)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
||||||
for pointer, origin := range origins {
|
for pointer, origin := range origins {
|
||||||
if _, present := decoded.presence[pointer]; present {
|
if _, present := decoded.presence[pointer]; present {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -357,6 +360,78 @@ escaped/~:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectedCompositionOriginsUseNoncontiguousItemMappings(t *testing.T) {
|
||||||
|
lowerRoot := parseCompositionDocument(t, "lower", `records:
|
||||||
|
- id: first
|
||||||
|
label: equal
|
||||||
|
- id: middle
|
||||||
|
- id: last
|
||||||
|
nested: {old: lower}
|
||||||
|
`)
|
||||||
|
higherRoot := parseCompositionDocument(t, "higher", `records:
|
||||||
|
- id: last
|
||||||
|
nested: {new: higher}
|
||||||
|
- id: first
|
||||||
|
label: equal
|
||||||
|
- id: added
|
||||||
|
`)
|
||||||
|
lower, _ := documentAtPath(lowerRoot, Keys("records"))
|
||||||
|
higher, _ := documentAtPath(higherRoot, Keys("records"))
|
||||||
|
combined, diagnostics := combineSequence(
|
||||||
|
lower, higher, sequenceCombinationPolicy(sequenceModeUpsert, "id"),
|
||||||
|
sequenceCombinationContext{destination: Keys("records")},
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("combine diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
inputs := []*document.Node{lowerRoot, higherRoot}
|
||||||
|
ranges := []sequenceRange{
|
||||||
|
{
|
||||||
|
sourceIndex: 0, path: Keys("records"), start: 0, end: 3,
|
||||||
|
items: combined.lowerItems,
|
||||||
|
}, {
|
||||||
|
sourceIndex: 1, path: Keys("records"), start: 0, end: 3,
|
||||||
|
items: combined.higherItems,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
origins := projectedCompositionOrigins(inputs, ranges)
|
||||||
|
if got, want := sequenceIdentityValues(combined.value, "id"), []string{"first", "middle", "last", "added"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("combined IDs = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for path, want := range map[string][]string{
|
||||||
|
"/records/0/label": {"higher"},
|
||||||
|
"/records/1/id": {"lower"},
|
||||||
|
"/records/2/nested/new": {"higher"},
|
||||||
|
"/records/3/id": {"higher"},
|
||||||
|
"/records/0/nested/new": nil,
|
||||||
|
"/records/2/nested/old": nil,
|
||||||
|
} {
|
||||||
|
origin := origins[path]
|
||||||
|
var got []string
|
||||||
|
for _, location := range origin.Locations {
|
||||||
|
got = append(got, location.SourceID)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("origin sources at %s = %#v, want %#v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneSequenceRangesPreservesPreparedProjection(t *testing.T) {
|
||||||
|
ranges := []sequenceRange{{
|
||||||
|
start: 0, end: 1,
|
||||||
|
projection: newSequenceItemProjectionIndex([]sequenceItemProjection{{inputIndex: 0, outputIndex: 4}}),
|
||||||
|
}}
|
||||||
|
cloned := cloneSequenceRanges(ranges)
|
||||||
|
if output, ok := cloned[0].outputForInput(0); !ok || output != 4 {
|
||||||
|
t.Fatalf("cloned projection = %d, %v; want output index 4", output, ok)
|
||||||
|
}
|
||||||
|
cloned[0].projection.outputByInput[0] = 9
|
||||||
|
if output, ok := ranges[0].outputForInput(0); !ok || output != 4 {
|
||||||
|
t.Fatalf("cloned projection aliases original: %d, %v", output, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -365,15 +440,12 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
|||||||
wantCode Code
|
wantCode Code
|
||||||
}{
|
}{
|
||||||
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
||||||
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||||
{name: "null identity", body: "records: [{id: null}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
|
||||||
{name: "numeric identity", body: "records: [{id: 12}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
|
||||||
{name: "boolean identity", body: "records: [{id: false}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -386,14 +458,228 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
|
||||||
|
path := Keys("records")
|
||||||
|
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
|
||||||
|
path.String(): {
|
||||||
|
destination: path,
|
||||||
|
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, lower, nil,
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: [{id: second}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose strict peers with lower identity: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
value, present := documentAtPath(composed.root, path)
|
||||||
|
if !present || value.Kind != document.Sequence || len(value.Sequence) != 2 {
|
||||||
|
t.Fatalf("composed records = %#v, %v; want two strict peer records", value, present)
|
||||||
|
}
|
||||||
|
resolved, present := composed.collectionPolicies.collections[path.String()]
|
||||||
|
if !present || !resolved.policy.identityPresent || !reflect.DeepEqual(resolved.policy.identityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("composed collection policy = %#v, %v; want retained lower identity", resolved, present)
|
||||||
|
}
|
||||||
|
clone := cloneComposedDocument(composed)
|
||||||
|
resolved.policy.identityFields[0] = "mutated"
|
||||||
|
if !reflect.DeepEqual(clone.collectionPolicies.collections[path.String()].policy.identityFields, []string{"id"}) {
|
||||||
|
t.Fatal("cloned composition shares mutable collection identity fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsReturnsSparseCollectionPolicies(t *testing.T) {
|
||||||
|
itemsPath := Keys("items")
|
||||||
|
unrelatedPath := Keys("unrelated")
|
||||||
|
var lower collectionPolicyScope
|
||||||
|
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
unrelated := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||||
|
unrelated.setRecordPolicy(newCollectionRecordPolicy())
|
||||||
|
lower.set(unrelatedPath, unrelated)
|
||||||
|
|
||||||
|
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, lower, nil,
|
||||||
|
parseCompositionDocument(t, "incoming", "items: [{id: current}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose incoming sequence: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(composed.collectionPolicies.collections) != 1 {
|
||||||
|
t.Fatalf("incoming collection policies = %#v; want only touched destinations", composed.collectionPolicies.collections)
|
||||||
|
}
|
||||||
|
if policy, found := composed.collectionPolicies.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("resolved incoming policy = %#v, found %t", policy, found)
|
||||||
|
}
|
||||||
|
if _, found := composed.collectionPolicies.at(unrelatedPath); found {
|
||||||
|
t.Fatal("incoming composition copied an unrelated lower policy")
|
||||||
|
}
|
||||||
|
if policy, found := lower.at(unrelatedPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"name"}) || len(policy.recordOrder) != 1 {
|
||||||
|
t.Fatalf("borrowed lower policy changed during composition: %#v, found %t", policy, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
composed.collectionPolicies.collections[itemsPath.String()].policy.identityFields[0] = "changed"
|
||||||
|
if policy, _ := lower.at(itemsPath); !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("incoming policy shares identity storage with lower scope: %#v", policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrictCompositionMasksLowerIdentityForReplacedAncestor(t *testing.T) {
|
||||||
|
itemsPath := Keys("catalog", "items")
|
||||||
|
var lower collectionPolicyScope
|
||||||
|
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
declaration := sequenceDeclarationSpec{destination: Keys("catalog"), mode: sequenceModeReplace, modePresent: true}
|
||||||
|
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||||
|
parseCompositionDocument(t, "source-1", "catalog: {items: [{name: first}]}\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "catalog: {items: [{name: second}]}\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/catalog/items" {
|
||||||
|
t.Fatalf("strict peers used identity masked by replaced ancestor: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if policy, found := lower.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||||
|
t.Fatalf("strict resolution mutated borrowed lower identity: %#v, found %t", policy, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsDeclarationWithoutValueRetainsOwnedRecordPolicy(t *testing.T) {
|
||||||
|
path := Keys("items")
|
||||||
|
nestedPath := Keys("prompts")
|
||||||
|
var nested collectionPolicyScope
|
||||||
|
nested.set(nestedPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
|
||||||
|
record := collectionRecordPolicy{owner: newCollectionRecordPolicy().owner, scope: nested}
|
||||||
|
lowerPolicy := collectionPolicy{
|
||||||
|
identityFields: []string{"id"}, identityPresent: true,
|
||||||
|
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||||
|
}
|
||||||
|
lowerPolicy.setRecordPolicy(record)
|
||||||
|
var lower collectionPolicyScope
|
||||||
|
lower.set(path, lowerPolicy)
|
||||||
|
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
|
||||||
|
|
||||||
|
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||||
|
parseCompositionDocument(t, "metadata", "other: value\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose declaration without a value: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
resolved, found := composed.collectionPolicies.at(path)
|
||||||
|
if !found || !resolved.identityPresent || !reflect.DeepEqual(resolved.identityFields, []string{"id"}) || resolved.explicitMode != sequenceModeReplace || !resolved.explicitModePresent {
|
||||||
|
t.Fatalf("declaration-only result changed retained collection policy: %#v, found %t", resolved, found)
|
||||||
|
}
|
||||||
|
if len(resolved.recordOrder) != 1 || len(resolved.recordPolicies) != 1 {
|
||||||
|
t.Fatalf("declaration-only result lost record ownership: %#v", resolved)
|
||||||
|
}
|
||||||
|
owner := resolved.recordOrder[0]
|
||||||
|
resolved.recordPolicies[owner].collections[nestedPath.String()].policy.identityFields[0] = "changed"
|
||||||
|
retained, _ := lower.at(path)
|
||||||
|
if nestedPolicy, found := retained.recordPolicies[owner].at(nestedPath); !found || !reflect.DeepEqual(nestedPolicy.identityFields, []string{"name"}) {
|
||||||
|
t.Fatalf("declaration-only result borrowed nested record policy: %#v, found %t", nestedPolicy, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
|
||||||
|
path := Keys("records")
|
||||||
|
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
|
||||||
|
location := Location{SourceID: source, Line: line}
|
||||||
|
return sequenceDeclarationSpec{
|
||||||
|
destination: path, identityFields: fields, identityPresent: true,
|
||||||
|
identityLocation: &location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, collectionPolicyScope{},
|
||||||
|
[]sequenceDeclarationSpec{declaration("source-1", 2, "id"), declaration("source-2", 4, "name")},
|
||||||
|
parseCompositionDocument(t, "source-1", "left: true\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "right: true\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/records" {
|
||||||
|
t.Fatalf("conflicting policy-only declarations = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
|
||||||
|
t.Fatalf("declaration conflict lacks both locations: %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, collectionPolicyScope{},
|
||||||
|
[]sequenceDeclarationSpec{declaration("source-1", 2, "tenant", "id"), declaration("source-2", 4, "id", "tenant")},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{tenant: true, id: null}, {tenant: \"alpha\", id: 1}]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: [{id: 1.0, tenant: \"beta\"}, {id: \"1\", tenant: \"alpha\"}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose normalized compound identities: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
value, present := documentAtPath(composed.root, path)
|
||||||
|
if !present || len(value.Sequence) != 4 {
|
||||||
|
t.Fatalf("compound identity records = %#v, %v; want four distinct records", value, present)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, diagnostics = composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, collectionPolicyScope{},
|
||||||
|
[]sequenceDeclarationSpec{declaration("source-1", 2, "id")},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{id: 1}]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: [{id: 1.0}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||||
|
t.Fatalf("equivalent numeric peer identities = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T) {
|
||||||
|
path := Keys("records")
|
||||||
|
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
|
||||||
|
path.String(): {
|
||||||
|
destination: path,
|
||||||
|
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
|
||||||
|
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||||
|
t.Fatalf("replacement peers borrowing lower identity = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
identityReplacement := sequenceDeclarationSpec{
|
||||||
|
destination: path, identityFields: []string{"name"}, identityPresent: true,
|
||||||
|
mode: sequenceModeReplace, modePresent: true,
|
||||||
|
}
|
||||||
|
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, lower, []sequenceDeclarationSpec{identityReplacement},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("replacement with a new identity declaration: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
resolved := composed.collectionPolicies.collections[path.String()].policy
|
||||||
|
if !reflect.DeepEqual(resolved.identityFields, []string{"name"}) || !resolved.identityPresent {
|
||||||
|
t.Fatalf("replacement collection identity = %#v; want name", resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, diagnostics = composeDocumentsWithCollectionPolicy(
|
||||||
|
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), lower,
|
||||||
|
[]sequenceDeclarationSpec{declaration},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/0/id" {
|
||||||
|
t.Fatalf("replacement ignored application identity constraint = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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"),
|
||||||
)
|
)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1/id" {
|
||||||
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics)
|
t.Fatalf("diagnostics = %#v, want invalid identity field at /records/1/id", diagnostics)
|
||||||
}
|
}
|
||||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||||
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)
|
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)
|
||||||
@@ -420,7 +706,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)
|
||||||
@@ -438,9 +724,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:
|
||||||
@@ -454,7 +743,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"),
|
||||||
)
|
)
|
||||||
@@ -471,7 +760,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"),
|
||||||
)
|
)
|
||||||
@@ -491,7 +780,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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
|||||||
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||||
}
|
}
|
||||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
if mutateExistingDocumentDescendant(composed.root) {
|
||||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||||
t.Fatal("composition result aliases an input document")
|
t.Fatal("composition result aliases an input document")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
base, baseDiagnostics := composeDocuments(left)
|
base, baseDiagnostics := composeDocuments(left)
|
||||||
if len(baseDiagnostics) != 0 {
|
if len(baseDiagnostics) != 0 {
|
||||||
@@ -60,9 +61,41 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
|||||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||||
t.Fatal("overlay mutated an input document")
|
t.Fatal("overlay mutated an input document")
|
||||||
}
|
}
|
||||||
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
if mutateExistingDocumentDescendant(overlaid.root) {
|
||||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||||
t.Fatal("overlay result aliases an input document")
|
t.Fatal("overlay result aliases an input document")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mutateExistingDocumentDescendant(node *document.Node) bool {
|
||||||
|
if node == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Empty containers have no existing element for the alias probe.
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if len(node.Mapping) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
entry := &node.Mapping[0]
|
||||||
|
if mutateExistingDocumentDescendant(entry.Value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry.Key += "-fuzz-detachment"
|
||||||
|
return true
|
||||||
|
case document.Sequence:
|
||||||
|
if len(node.Sequence) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if mutateExistingDocumentDescendant(node.Sequence[0]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
node.Sequence[0].Text += "fuzz-detachment"
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
node.Text += "fuzz-detachment"
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type decodeIssue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type inputPresence struct {
|
type inputPresence struct {
|
||||||
null bool
|
|
||||||
location document.Location
|
location document.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,19 +90,30 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if descriptor.kind == descriptorPointer {
|
if descriptor.kind == descriptorPointer {
|
||||||
var elementBase reflect.Value
|
terminal, ok := terminalDescriptor(descriptor)
|
||||||
if base.IsNil() {
|
if !ok {
|
||||||
elementBase = reflect.Zero(descriptor.typeOf.Elem())
|
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration type cannot represent a non-null value")}
|
||||||
} else {
|
|
||||||
elementBase = base.Elem()
|
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
var pointers []*typeDescriptor
|
||||||
|
currentBase := base
|
||||||
|
for current := descriptor; current != terminal; current = current.element {
|
||||||
|
pointers = append(pointers, current)
|
||||||
|
if currentBase.IsNil() {
|
||||||
|
currentBase = reflect.Zero(current.typeOf.Elem())
|
||||||
|
} else {
|
||||||
|
currentBase = currentBase.Elem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoded, issues := decodeValue(node, currentBase, terminal, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
result := reflect.New(descriptor.typeOf.Elem())
|
for i := len(pointers) - 1; i >= 0; i-- {
|
||||||
|
result := reflect.New(pointers[i].typeOf.Elem())
|
||||||
result.Elem().Set(decoded)
|
result.Elem().Set(decoded)
|
||||||
return result, nil
|
decoded = result
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
@@ -392,10 +402,11 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acceptsMapping(descriptor *typeDescriptor) bool {
|
func acceptsMapping(descriptor *typeDescriptor) bool {
|
||||||
for descriptor != nil && descriptor.kind == descriptorPointer {
|
terminal, ok := terminalDescriptor(descriptor)
|
||||||
descriptor = descriptor.element
|
if !ok {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic)
|
return terminal.kind == descriptorStruct || terminal.kind == descriptorMap || terminal.kind == descriptorDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
|
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
|
||||||
@@ -422,7 +433,7 @@ func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Val
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
|
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
|
||||||
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location}
|
presence[path.String()] = inputPresence{location: node.Location}
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case document.Mapping:
|
case document.Mapping:
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
|
|||||||
100
internal/engine/dependency_integration_test.go
Normal file
100
internal/engine/dependency_integration_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportSchedulingKeepsUnselectedSlotsAndScopeBoundaries(t *testing.T) {
|
||||||
|
dependent := importNodeKey{resourcePath: "a-dependent"}
|
||||||
|
external := importNodeKey{resourcePath: "external"}
|
||||||
|
prerequisite := importNodeKey{resourcePath: "z-prerequisite"}
|
||||||
|
graph := newImportInclusionGraph()
|
||||||
|
for _, key := range []importNodeKey{dependent, external, prerequisite} {
|
||||||
|
graph.addNode(importGraphNode{key: key})
|
||||||
|
}
|
||||||
|
graph.addEdge(dependent, prerequisite, Location{})
|
||||||
|
graph.addEdge(dependent, external, Location{})
|
||||||
|
graph.addEdge(external, prerequisite, Location{})
|
||||||
|
contributions := []acquiredContribution{
|
||||||
|
{identity: dependent}, {identity: external}, {identity: prerequisite},
|
||||||
|
}
|
||||||
|
scheduled := scheduleContributionSlots(contributions, []int{0, 2}, indexImportDependencies(graph))
|
||||||
|
if scheduled[0].identity != prerequisite || scheduled[1].identity != external || scheduled[2].identity != dependent {
|
||||||
|
t.Fatalf("scope schedule = %#v", scheduled)
|
||||||
|
}
|
||||||
|
if contributions[0].identity != dependent || contributions[2].identity != prerequisite {
|
||||||
|
t.Fatal("scheduling changed the input slots")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
|
||||||
|
names := []string{"a-base", "b-independent", "z-dependent"}
|
||||||
|
keys := make(map[string]importNodeKey, len(names))
|
||||||
|
contributions := make([]acquiredContribution, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
key := importNodeKey{
|
||||||
|
namespaceKind: "host", namespaceAnchor: "/shared",
|
||||||
|
resourceKind: "file", resourcePath: "/" + name,
|
||||||
|
}
|
||||||
|
keys[name] = key
|
||||||
|
contributions[index] = acquiredContribution{
|
||||||
|
identity: key,
|
||||||
|
source: SourceInfo{ID: "source-" + name, Path: name},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var want []string
|
||||||
|
for _, reverse := range []bool{false, true} {
|
||||||
|
graph := newImportInclusionGraph()
|
||||||
|
orderedNames := append([]string(nil), names...)
|
||||||
|
if reverse {
|
||||||
|
orderedNames[0], orderedNames[2] = orderedNames[2], orderedNames[0]
|
||||||
|
}
|
||||||
|
for _, name := range orderedNames {
|
||||||
|
graph.addNode(importGraphNode{key: keys[name], sourceID: "source-" + name, role: importRoleMapping})
|
||||||
|
}
|
||||||
|
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
||||||
|
|
||||||
|
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, indexImportDependencies(graph))
|
||||||
|
got := make([]string, len(scheduled))
|
||||||
|
for index, contribution := range scheduled {
|
||||||
|
got[index] = contribution.identity.resourcePath[1:]
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, []string{"a-base", "b-independent", "z-dependent"}) {
|
||||||
|
t.Fatalf("import schedule reverse=%t: %v", reverse, got)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(
|
||||||
|
[]string{contributions[0].source.ID, contributions[1].source.ID, contributions[2].source.ID},
|
||||||
|
[]string{"source-a-base", "source-b-independent", "source-z-dependent"},
|
||||||
|
) {
|
||||||
|
t.Fatal("scheduling mutated acquisition source records")
|
||||||
|
}
|
||||||
|
|
||||||
|
inheritanceText := "a-base: {value: ready}\nb-independent: {value: independent}\nz-dependent: {extends: a-base}\n"
|
||||||
|
if reverse {
|
||||||
|
inheritanceText = "z-dependent: {extends: a-base}\nb-independent: {value: independent}\na-base: {value: ready}\n"
|
||||||
|
}
|
||||||
|
scope := parseCompositionDocument(t, "inheritance", inheritanceText)
|
||||||
|
inheritance, diagnostics := analyzeInheritanceGraph(scope, Path{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("analyze inheritance reverse=%t: %#v", reverse, diagnostics)
|
||||||
|
}
|
||||||
|
order, diagnostics := inheritance.schedule()
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("schedule inheritance reverse=%t: %#v", reverse, diagnostics)
|
||||||
|
}
|
||||||
|
inheritanceNames := make([]string, len(order))
|
||||||
|
for index, node := range order {
|
||||||
|
inheritanceNames[index] = inheritance.nodes[node].name
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(inheritanceNames, got) {
|
||||||
|
t.Fatalf("equivalent graph schedules differ reverse=%t: imports=%v inheritance=%v", reverse, got, inheritanceNames)
|
||||||
|
}
|
||||||
|
if want == nil {
|
||||||
|
want = got
|
||||||
|
} else if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("import graph construction changed schedule: got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
internal/engine/dependency_order.go
Normal file
200
internal/engine/dependency_order.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/heap"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dependencyKey is an immutable tuple used to order dependency-graph nodes.
|
||||||
|
// Callers provide unique keys and valid edge endpoints.
|
||||||
|
type dependencyKey []string
|
||||||
|
|
||||||
|
// dependencyEdge points from a prerequisite to the node that depends on it.
|
||||||
|
type dependencyEdge struct {
|
||||||
|
prerequisite int
|
||||||
|
dependent int
|
||||||
|
}
|
||||||
|
|
||||||
|
type dependencyReadyHeap struct {
|
||||||
|
nodes []int
|
||||||
|
keys []dependencyKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h dependencyReadyHeap) Len() int { return len(h.nodes) }
|
||||||
|
|
||||||
|
func (h dependencyReadyHeap) Less(i, j int) bool {
|
||||||
|
return dependencyKeyLess(h.keys[h.nodes[i]], h.keys[h.nodes[j]])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h dependencyReadyHeap) Swap(i, j int) {
|
||||||
|
h.nodes[i], h.nodes[j] = h.nodes[j], h.nodes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *dependencyReadyHeap) Push(value any) {
|
||||||
|
h.nodes = append(h.nodes, value.(int))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *dependencyReadyHeap) Pop() any {
|
||||||
|
last := len(h.nodes) - 1
|
||||||
|
node := h.nodes[last]
|
||||||
|
h.nodes = h.nodes[:last]
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// dependencyKeyLess compares tuple components bytewise, with a shorter tuple
|
||||||
|
// sorting first when all of its components match the corresponding prefix.
|
||||||
|
func dependencyKeyLess(left, right dependencyKey) bool {
|
||||||
|
for i := 0; i < len(left) && i < len(right); i++ {
|
||||||
|
if left[i] != right[i] {
|
||||||
|
return left[i] < right[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(left) < len(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleDependencies applies Kahn's algorithm, always selecting the
|
||||||
|
// smallest currently-ready key. Duplicate edges count only once. If scheduling
|
||||||
|
// stalls, an explicit depth-first traversal of the residual graph returns an
|
||||||
|
// actual directed cycle, rotated to its smallest key and closed by repeating
|
||||||
|
// its first node; blocked non-cycle dependents are excluded.
|
||||||
|
//
|
||||||
|
// Nodes are visited by precomputed key rank, which also makes each adjacency
|
||||||
|
// list deterministic without sorting edges. The work is O(E + V log V), with
|
||||||
|
// O(V + E) additional memory, apart from key copying and tuple comparisons.
|
||||||
|
func scheduleDependencies(keys []dependencyKey, edges []dependencyEdge) (order, cycle []int) {
|
||||||
|
ownedKeys := make([]dependencyKey, len(keys))
|
||||||
|
for i, key := range keys {
|
||||||
|
ownedKeys[i] = append(dependencyKey(nil), key...)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeOrder := make([]int, len(ownedKeys))
|
||||||
|
for i := range nodeOrder {
|
||||||
|
nodeOrder[i] = i
|
||||||
|
}
|
||||||
|
sort.Slice(nodeOrder, func(i, j int) bool {
|
||||||
|
return dependencyKeyLess(ownedKeys[nodeOrder[i]], ownedKeys[nodeOrder[j]])
|
||||||
|
})
|
||||||
|
|
||||||
|
incoming := make([][]int, len(ownedKeys))
|
||||||
|
for _, edge := range edges {
|
||||||
|
incoming[edge.dependent] = append(incoming[edge.dependent], edge.prerequisite)
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoing := make([][]int, len(ownedKeys))
|
||||||
|
indegree := make([]int, len(ownedKeys))
|
||||||
|
for _, dependent := range nodeOrder {
|
||||||
|
seen := make(map[int]struct{}, len(incoming[dependent]))
|
||||||
|
for _, prerequisite := range incoming[dependent] {
|
||||||
|
if _, duplicate := seen[prerequisite]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[prerequisite] = struct{}{}
|
||||||
|
indegree[dependent]++
|
||||||
|
// Dependents are visited by key rank, so each outgoing list is
|
||||||
|
// already ordered by key.
|
||||||
|
outgoing[prerequisite] = append(outgoing[prerequisite], dependent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ready := dependencyReadyHeap{keys: ownedKeys}
|
||||||
|
for _, node := range nodeOrder {
|
||||||
|
if indegree[node] == 0 {
|
||||||
|
ready.nodes = append(ready.nodes, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heap.Init(&ready)
|
||||||
|
|
||||||
|
order = make([]int, 0, len(ownedKeys))
|
||||||
|
for ready.Len() != 0 {
|
||||||
|
node := heap.Pop(&ready).(int)
|
||||||
|
order = append(order, node)
|
||||||
|
for _, dependent := range outgoing[node] {
|
||||||
|
indegree[dependent]--
|
||||||
|
if indegree[dependent] == 0 {
|
||||||
|
heap.Push(&ready, dependent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(order) == len(ownedKeys) {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
return nil, dependencyCycle(ownedKeys, nodeOrder, outgoing, indegree)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dependencyDFSFrame struct {
|
||||||
|
node int
|
||||||
|
next int
|
||||||
|
}
|
||||||
|
|
||||||
|
// dependencyCycle searches the residual graph iteratively to avoid recursion
|
||||||
|
// depth depending on input size.
|
||||||
|
func dependencyCycle(keys []dependencyKey, nodeOrder []int, outgoing [][]int, indegree []int) []int {
|
||||||
|
const (
|
||||||
|
unvisited = uint8(0)
|
||||||
|
active = uint8(1)
|
||||||
|
finished = uint8(2)
|
||||||
|
)
|
||||||
|
|
||||||
|
state := make([]uint8, len(keys))
|
||||||
|
position := make([]int, len(keys))
|
||||||
|
for i := range position {
|
||||||
|
position[i] = -1
|
||||||
|
}
|
||||||
|
path := make([]int, 0, len(keys))
|
||||||
|
frames := make([]dependencyDFSFrame, 0, len(keys))
|
||||||
|
|
||||||
|
for _, root := range nodeOrder {
|
||||||
|
if indegree[root] == 0 || state[root] != unvisited {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state[root] = active
|
||||||
|
position[root] = len(path)
|
||||||
|
path = append(path, root)
|
||||||
|
frames = append(frames, dependencyDFSFrame{node: root})
|
||||||
|
|
||||||
|
for len(frames) != 0 {
|
||||||
|
frame := &frames[len(frames)-1]
|
||||||
|
if frame.next == len(outgoing[frame.node]) {
|
||||||
|
state[frame.node] = finished
|
||||||
|
position[frame.node] = -1
|
||||||
|
path = path[:len(path)-1]
|
||||||
|
frames = frames[:len(frames)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
next := outgoing[frame.node][frame.next]
|
||||||
|
frame.next++
|
||||||
|
if indegree[next] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch state[next] {
|
||||||
|
case unvisited:
|
||||||
|
state[next] = active
|
||||||
|
position[next] = len(path)
|
||||||
|
path = append(path, next)
|
||||||
|
frames = append(frames, dependencyDFSFrame{node: next})
|
||||||
|
case active:
|
||||||
|
return canonicalDependencyCycle(keys, path[position[next]:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stalled Kahn traversal always leaves a cycle in the residual graph.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalDependencyCycle(keys []dependencyKey, path []int) []int {
|
||||||
|
start := 0
|
||||||
|
for i := 1; i < len(path); i++ {
|
||||||
|
if dependencyKeyLess(keys[path[i]], keys[path[start]]) {
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cycle := make([]int, len(path)+1)
|
||||||
|
for i := range path {
|
||||||
|
cycle[i] = path[(start+i)%len(path)]
|
||||||
|
}
|
||||||
|
cycle[len(path)] = cycle[0]
|
||||||
|
return cycle
|
||||||
|
}
|
||||||
215
internal/engine/dependency_order_test.go
Normal file
215
internal/engine/dependency_order_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScheduleDependenciesOrdersReadyNodes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keys []string
|
||||||
|
edges [][2]string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "independent branches",
|
||||||
|
keys: []string{"branch-c", "branch-a", "branch-b"},
|
||||||
|
want: []string{"branch-a", "branch-b", "branch-c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unequal chains",
|
||||||
|
keys: []string{"chain-d", "chain-c", "chain-b", "chain-a"},
|
||||||
|
edges: [][2]string{
|
||||||
|
{"chain-a", "chain-b"},
|
||||||
|
{"chain-b", "chain-c"},
|
||||||
|
{"chain-c", "chain-d"},
|
||||||
|
},
|
||||||
|
want: []string{"chain-a", "chain-b", "chain-c", "chain-d"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple dependencies",
|
||||||
|
keys: []string{"application", "right", "base", "left"},
|
||||||
|
edges: [][2]string{
|
||||||
|
{"base", "left"}, {"base", "right"},
|
||||||
|
{"left", "application"}, {"right", "application"},
|
||||||
|
},
|
||||||
|
want: []string{"base", "left", "right", "application"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shared dependency at equal depths",
|
||||||
|
keys: []string{"sink", "right", "root", "left"},
|
||||||
|
edges: [][2]string{
|
||||||
|
{"root", "left"}, {"root", "right"},
|
||||||
|
{"left", "sink"}, {"right", "sink"},
|
||||||
|
},
|
||||||
|
want: []string{"root", "left", "right", "sink"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shared dependency at unequal depths",
|
||||||
|
keys: []string{"sink", "deep", "side", "middle", "root"},
|
||||||
|
edges: [][2]string{
|
||||||
|
{"root", "middle"}, {"middle", "deep"}, {"root", "side"},
|
||||||
|
{"deep", "sink"}, {"side", "sink"},
|
||||||
|
},
|
||||||
|
want: []string{"root", "middle", "deep", "side", "sink"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newly ready small key precedes older large key",
|
||||||
|
keys: []string{"z-ready", "a-new", "b-ready"},
|
||||||
|
edges: [][2]string{{"b-ready", "a-new"}},
|
||||||
|
want: []string{"b-ready", "a-new", "z-ready"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate edges count once",
|
||||||
|
keys: []string{"dependent", "prerequisite"},
|
||||||
|
edges: [][2]string{
|
||||||
|
{"prerequisite", "dependent"},
|
||||||
|
{"prerequisite", "dependent"},
|
||||||
|
},
|
||||||
|
want: []string{"prerequisite", "dependent"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
|
||||||
|
order, cycle := scheduleDependencies(keys, edges)
|
||||||
|
if len(cycle) != 0 {
|
||||||
|
t.Fatalf("unexpected cycle: %v", dependencyNames(cycle, indexes))
|
||||||
|
}
|
||||||
|
if got := dependencyNames(order, indexes); !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("schedule = %v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleDependenciesUsesTupleKeys(t *testing.T) {
|
||||||
|
keys := []dependencyKey{
|
||||||
|
{"a", "z"},
|
||||||
|
{"a"},
|
||||||
|
{"a", "aa"},
|
||||||
|
{"A"},
|
||||||
|
}
|
||||||
|
order, cycle := scheduleDependencies(keys, nil)
|
||||||
|
if len(cycle) != 0 {
|
||||||
|
t.Fatalf("unexpected cycle: %v", cycle)
|
||||||
|
}
|
||||||
|
want := []int{3, 1, 2, 0}
|
||||||
|
if !reflect.DeepEqual(order, want) {
|
||||||
|
t.Fatalf("schedule = %v, want %v", order, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleDependenciesReportsActualCanonicalCycles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keys []string
|
||||||
|
edges [][2]string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "self cycle",
|
||||||
|
keys: []string{"self"},
|
||||||
|
edges: [][2]string{{"self", "self"}},
|
||||||
|
want: []string{"self", "self"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-node cycle excludes blocked dependent",
|
||||||
|
keys: []string{"blocked", "cycle-b", "cycle-a"},
|
||||||
|
edges: [][2]string{
|
||||||
|
{"cycle-a", "cycle-b"},
|
||||||
|
{"cycle-b", "cycle-a"},
|
||||||
|
{"cycle-b", "blocked"},
|
||||||
|
},
|
||||||
|
want: []string{"cycle-a", "cycle-b", "cycle-a"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
|
||||||
|
order, cycle := scheduleDependencies(keys, edges)
|
||||||
|
if len(order) != 0 {
|
||||||
|
t.Fatalf("unexpected schedule: %v", dependencyNames(order, indexes))
|
||||||
|
}
|
||||||
|
if got := dependencyNames(cycle, indexes); !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("cycle = %v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleDependenciesIsStableAcrossInsertionPermutations(t *testing.T) {
|
||||||
|
edges := [][2]string{
|
||||||
|
{"base", "left"}, {"base", "right"},
|
||||||
|
{"left", "sink"}, {"right", "sink"},
|
||||||
|
}
|
||||||
|
permutations := [][]string{
|
||||||
|
{"sink", "right", "base", "left"},
|
||||||
|
{"left", "base", "sink", "right"},
|
||||||
|
{"right", "left", "base", "sink"},
|
||||||
|
}
|
||||||
|
for i, permutation := range permutations {
|
||||||
|
keys, permutedEdges, indexes := dependencyGraph(permutation, edges)
|
||||||
|
if i%2 != 0 {
|
||||||
|
reverseDependencyEdges(permutedEdges)
|
||||||
|
}
|
||||||
|
order, cycle := scheduleDependencies(keys, permutedEdges)
|
||||||
|
if len(cycle) != 0 {
|
||||||
|
t.Fatalf("permutation %d returned cycle %v", i, dependencyNames(cycle, indexes))
|
||||||
|
}
|
||||||
|
if got, want := dependencyNames(order, indexes), []string{"base", "left", "right", "sink"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("permutation %d schedule = %v, want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleEdges := [][2]string{{"b", "a"}, {"a", "b"}, {"b", "blocked"}}
|
||||||
|
cyclePermutations := [][]string{{"blocked", "b", "a"}, {"a", "blocked", "b"}, {"b", "a", "blocked"}}
|
||||||
|
for i, permutation := range cyclePermutations {
|
||||||
|
keys, permutedEdges, indexes := dependencyGraph(permutation, cycleEdges)
|
||||||
|
if i%2 != 0 {
|
||||||
|
reverseDependencyEdges(permutedEdges)
|
||||||
|
}
|
||||||
|
order, cycle := scheduleDependencies(keys, permutedEdges)
|
||||||
|
if len(order) != 0 {
|
||||||
|
t.Fatalf("cycle permutation %d returned schedule %v", i, dependencyNames(order, indexes))
|
||||||
|
}
|
||||||
|
if got, want := dependencyNames(cycle, indexes), []string{"a", "b", "a"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("cycle permutation %d returned %v, want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dependencyGraph(nodeNames []string, namedEdges [][2]string) ([]dependencyKey, []dependencyEdge, map[int]string) {
|
||||||
|
keys := make([]dependencyKey, len(nodeNames))
|
||||||
|
indexes := make(map[string]int, len(nodeNames))
|
||||||
|
for i, name := range nodeNames {
|
||||||
|
keys[i] = dependencyKey{name}
|
||||||
|
indexes[name] = i
|
||||||
|
}
|
||||||
|
edges := make([]dependencyEdge, len(namedEdges))
|
||||||
|
for i, edge := range namedEdges {
|
||||||
|
edges[i] = dependencyEdge{prerequisite: indexes[edge[0]], dependent: indexes[edge[1]]}
|
||||||
|
}
|
||||||
|
byIndex := make(map[int]string, len(nodeNames))
|
||||||
|
for name, index := range indexes {
|
||||||
|
byIndex[index] = name
|
||||||
|
}
|
||||||
|
return keys, edges, byIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func dependencyNames(nodes []int, names map[int]string) []string {
|
||||||
|
result := make([]string, len(nodes))
|
||||||
|
for i, node := range nodes {
|
||||||
|
result[i] = names[node]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseDependencyEdges(edges []dependencyEdge) {
|
||||||
|
for i, j := 0, len(edges)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
edges[i], edges[j] = edges[j], edges[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,33 +11,13 @@ var (
|
|||||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||||
)
|
)
|
||||||
|
|
||||||
type placementMode uint8
|
type PlacementMode uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
placementModeMapping placementMode = iota
|
PlacementModeMapping PlacementMode = iota
|
||||||
placementModeSequenceItem
|
PlacementModeSequenceItem
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlacementModeFromFacade preserves root-owned numeric values until preflight
|
|
||||||
// can reject unsupported modes.
|
|
||||||
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
|
|
||||||
|
|
||||||
// PlacementModeForFacade converts private source evidence to the root-owned mode.
|
|
||||||
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
|
|
||||||
|
|
||||||
// InputWithPlacement copies the root-owned mode into a private engine input.
|
|
||||||
func InputWithPlacement(input Input, value uint8) Input {
|
|
||||||
input.placementMode = placementMode(value)
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionsWithPlacement copies root-owned controls into private engine options.
|
|
||||||
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
|
|
||||||
options.destination = destination.clone()
|
|
||||||
options.placementMode = placementMode(value)
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeDestination(path Path) (Path, error) {
|
func normalizeDestination(path Path) (Path, error) {
|
||||||
if !path.valid() {
|
if !path.valid() {
|
||||||
return Path{}, errInvalidDestination
|
return Path{}, errInvalidDestination
|
||||||
@@ -74,26 +54,26 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
|||||||
// placeDocument checks all per-document bounds before allocating destination
|
// placeDocument checks all per-document bounds before allocating destination
|
||||||
// wrappers. Aggregate accounting remains the caller's responsibility.
|
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||||
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||||
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
|
return placeDocumentWithMode(body, destination, PlacementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// placeDocumentWithMode places a source mapping beneath its literal destination.
|
// placeDocumentWithMode places a source mapping beneath its literal destination.
|
||||||
// Sequence-item placement adds one sequence containing the complete mapping.
|
// Sequence-item placement adds one sequence containing the complete mapping.
|
||||||
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
func placeDocumentWithMode(body *document.Node, destination Path, mode PlacementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||||
normalized, err := normalizeDestination(destination)
|
normalized, err := normalizeDestination(destination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Path{}, nil, err
|
return Path{}, nil, err
|
||||||
}
|
}
|
||||||
if mode != placementModeMapping && mode != placementModeSequenceItem {
|
if mode != PlacementModeMapping && mode != PlacementModeSequenceItem {
|
||||||
return Path{}, nil, errInvalidDestination
|
return Path{}, nil, errInvalidDestination
|
||||||
}
|
}
|
||||||
if mode == placementModeSequenceItem && len(normalized.segments) == 0 {
|
if mode == PlacementModeSequenceItem && len(normalized.segments) == 0 {
|
||||||
return Path{}, nil, errInvalidDestination
|
return Path{}, nil, errInvalidDestination
|
||||||
}
|
}
|
||||||
if body == nil {
|
if body == nil {
|
||||||
return normalized, nil, nil
|
return normalized, nil, nil
|
||||||
}
|
}
|
||||||
if mode == placementModeSequenceItem && body.Kind != document.Mapping {
|
if mode == PlacementModeSequenceItem && body.Kind != document.Mapping {
|
||||||
return normalized, nil, errInvalidDestination
|
return normalized, nil, errInvalidDestination
|
||||||
}
|
}
|
||||||
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
|
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
|
||||||
@@ -101,7 +81,7 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
|
|||||||
}
|
}
|
||||||
|
|
||||||
placed := body
|
placed := body
|
||||||
if mode == placementModeSequenceItem {
|
if mode == PlacementModeSequenceItem {
|
||||||
placed = &document.Node{
|
placed = &document.Node{
|
||||||
Kind: document.Sequence, Location: sourceRoot,
|
Kind: document.Sequence, Location: sourceRoot,
|
||||||
Sequence: []*document.Node{placed},
|
Sequence: []*document.Node{placed},
|
||||||
@@ -125,20 +105,20 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
|
|||||||
|
|
||||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||||
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
|
return destinationWithinLimitsWithMode(body, destination, PlacementModeMapping, maxDepth, maxNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
|
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode PlacementMode, maxDepth, maxNodes int) bool {
|
||||||
if body == nil {
|
if body == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
|
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != PlacementModeMapping && mode != PlacementModeSequenceItem) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
segments := len(destination.segments)
|
segments := len(destination.segments)
|
||||||
sequenceNodes := 0
|
sequenceNodes := 0
|
||||||
sequenceDepth := 0
|
sequenceDepth := 0
|
||||||
if mode == placementModeSequenceItem {
|
if mode == PlacementModeSequenceItem {
|
||||||
if segments == 0 {
|
if segments == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -160,9 +140,9 @@ func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode
|
|||||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||||
}
|
}
|
||||||
|
|
||||||
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int {
|
func destinationWrapperNodeCountForMode(destination Path, mode PlacementMode) int {
|
||||||
count := destinationWrapperNodeCount(destination)
|
count := destinationWrapperNodeCount(destination)
|
||||||
if mode == placementModeSequenceItem {
|
if mode == PlacementModeSequenceItem {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -34,17 +34,17 @@ func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||||
source: empty, destination: destination,
|
source: empty, destination: destination,
|
||||||
}}}, 1, nil, accounting)
|
}}}, 1, nil, accounting)
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||||
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
|
t.Fatalf("over-budget placement = %#v, %#v", acquired, diagnostics)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
|
if acquired == nil || len(diagnostics) != 0 || acquired.inputNodes != test.wantCharge {
|
||||||
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
|
t.Fatalf("bounded placement = %#v, %#v", acquired, diagnostics)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -62,9 +62,9 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
|||||||
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||||
charge := len(inputs) * perInput
|
charge := len(inputs) * perInput
|
||||||
accounting := &importAccounting{nodes: limits.nodes - charge}
|
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||||
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
if len(diagnostics) != 0 || acquired == nil || accounting.nodes != limits.nodes || acquired.inputNodes != charge {
|
||||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", acquired, diagnostics, accounting, charge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +92,9 @@ id: parent
|
|||||||
childDestination := appendDestination(destination, Keys("children"))
|
childDestination := appendDestination(destination, Keys("children"))
|
||||||
otherDestination := Keys("other")
|
otherDestination := Keys("other")
|
||||||
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
||||||
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
|
destinationWrapperNodeCountForMode(destination, PlacementModeSequenceItem) +
|
||||||
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
|
destinationWrapperNodeCountForMode(childDestination, PlacementModeSequenceItem) +
|
||||||
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
|
destinationWrapperNodeCountForMode(otherDestination, PlacementModeSequenceItem)
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
@@ -107,22 +107,22 @@ id: parent
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
||||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||||
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
|
source: File(mainPath), imports: true, destination: destination, placementMode: PlacementModeSequenceItem,
|
||||||
}, {
|
}, {
|
||||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
|
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: PlacementModeSequenceItem,
|
||||||
}}}, 1, nil, accounting)
|
}}}, 1, nil, accounting)
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||||
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
|
t.Fatalf("under-budget nested item glob = %#v, %#v", acquired, diagnostics)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
|
if acquired == nil || len(diagnostics) != 0 || len(acquired.sources) != 3 {
|
||||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", acquired, acquired.sources, diagnostics)
|
||||||
}
|
}
|
||||||
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
if acquired.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
t.Fatalf("nested item node charge = %d/%d, want %d/%d", acquired.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
composition, sources, _, diagnostics := testComposeUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||||
source: mainSource, imports: true, destination: destination,
|
source: mainSource, imports: true, destination: destination,
|
||||||
}}}, 1, nil, accounting)
|
}}}, 1, nil, accounting)
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
@@ -216,24 +216,27 @@ func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
|
|||||||
currentLimits := limits
|
currentLimits := limits
|
||||||
currentLimits.nodes = wantNodes + test.delta
|
currentLimits.nodes = wantNodes + test.delta
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
|
||||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||||
)
|
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
|
t.Fatalf("below-bound relative placement = %#v, %#v", acquired, diagnostics)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
if len(diagnostics) != 0 || acquired == nil || len(sources) != 2 {
|
||||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
t.Fatalf("bounded relative placement = %#v, %#v, %#v", acquired, sources, diagnostics)
|
||||||
}
|
}
|
||||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||||
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
t.Fatalf("relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||||
}
|
}
|
||||||
if sources[1].Destination.String() != importDestination.String() {
|
if sources[1].Destination.String() != importDestination.String() {
|
||||||
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
|
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
|
||||||
}
|
}
|
||||||
|
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose relative placement: %#v", diagnostics)
|
||||||
|
}
|
||||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
t.Fatalf("empty relative import body = %#v, present %v", node, present)
|
t.Fatalf("empty relative import body = %#v, present %v", node, present)
|
||||||
}
|
}
|
||||||
@@ -279,26 +282,29 @@ func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
|
|||||||
currentLimits := limits
|
currentLimits := limits
|
||||||
currentLimits.nodes = wantNodes + test.delta
|
currentLimits.nodes = wantNodes + test.delta
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
|
||||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||||
)
|
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
|
t.Fatalf("below-bound glob placement = %#v, %#v", acquired, diagnostics)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 {
|
if len(diagnostics) != 0 || acquired == nil || len(sources) != len(fragmentPaths)+1 {
|
||||||
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
t.Fatalf("bounded glob placement = %#v, %#v, %#v", acquired, sources, diagnostics)
|
||||||
}
|
}
|
||||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||||
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
t.Fatalf("glob relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||||
}
|
}
|
||||||
for index, source := range sources[1:] {
|
for index, source := range sources[1:] {
|
||||||
if source.Destination.String() != importDestination.String() {
|
if source.Destination.String() != importDestination.String() {
|
||||||
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
|
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose glob placement: %#v", diagnostics)
|
||||||
|
}
|
||||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
|
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
|
||||||
}
|
}
|
||||||
@@ -345,11 +351,11 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "optional missing path",
|
name: "optional missing path",
|
||||||
body: "configkit: {imports: [{path: missing.yml, optional: true, destination: [unused]}]}\n",
|
body: "configkit: {imports: [{path: missing.yml, destination: [unused]}]}\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "optional unmatched glob",
|
name: "optional unmatched glob",
|
||||||
body: "configkit: {imports: [{glob: empty/*.yml, optional: true, destination: [unused]}]}\n",
|
body: "configkit: {imports: [{glob: empty/*.yml, destination: [unused]}]}\n",
|
||||||
setup: func(t *testing.T, directory string) {
|
setup: func(t *testing.T, directory string) {
|
||||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -366,15 +372,18 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
|||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
request := testImportRequest(File(mainPath), nil, main, destination, nil, nil)
|
||||||
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
|
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
|
||||||
)
|
if len(diagnostics) != 0 || acquired == nil || len(sources) != 1 {
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
t.Fatalf("absent optional import = %#v, %#v, %#v", acquired, sources, diagnostics)
|
||||||
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
|
||||||
}
|
}
|
||||||
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
|
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
|
||||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||||
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
t.Fatalf("absent optional node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||||
|
}
|
||||||
|
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose absent optional import: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
t.Fatalf("empty entry body = %#v, present %v", node, present)
|
t.Fatalf("empty entry body = %#v, present %v", node, present)
|
||||||
@@ -387,12 +396,12 @@ func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||||
{source: NoSource(), destination: Keys("absent")},
|
{source: NoSource(), destination: Keys("absent")},
|
||||||
{source: Mapping("root", map[string]any{})},
|
{source: Mapping("root", map[string]any{})},
|
||||||
}}, 1, nil, accounting)
|
}}, 1, nil, accounting)
|
||||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
|
if len(diagnostics) != 0 || acquired == nil || len(acquired.sources) != 1 || accounting.nodes != 1 || acquired.inputNodes != 1 {
|
||||||
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
|
t.Fatalf("root and absent placement = %#v, diagnostics %#v, accounting %#v", acquired, diagnostics, accounting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +421,7 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
|||||||
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||||
}
|
}
|
||||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
|
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
|
||||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
_, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||||
t.Fatalf("overlay diagnostics = %#v", diagnostics)
|
t.Fatalf("overlay diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -431,9 +440,27 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
|||||||
overrides: []Override{override},
|
overrides: []Override{override},
|
||||||
}
|
}
|
||||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
||||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
input, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("assembly diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = preparePostStructuralOverrides(overridePreparationInput{
|
||||||
|
root: input.document, inputBytes: accounting.bytes, inputNodes: accounting.nodes,
|
||||||
|
}, input.sources, request.overrides, nil, limits)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
|
||||||
t.Fatalf("override diagnostics = %#v", diagnostics)
|
t.Fatalf("override diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAssembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||||
|
if accounting == nil {
|
||||||
|
accounting = &importAccounting{}
|
||||||
|
}
|
||||||
|
policy := acquisitionPolicy{
|
||||||
|
importDefaults: request.importDefaults,
|
||||||
|
sequencePolicy: request.sequencePolicy,
|
||||||
|
importLimits: defaultImportLimits(),
|
||||||
|
}
|
||||||
|
return assembleLayeredInput(request, input, policy, &acquisitionState{accounting: accounting, versionState: input.versionState})
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,10 +108,14 @@ parent: true
|
|||||||
}
|
}
|
||||||
defer func() { _ = access.close() }()
|
defer func() { _ = access.close() }()
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
|
access: access,
|
||||||
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
|
policy: &acquisitionPolicy{importLimits: defaultImportLimits()},
|
||||||
|
state: &acquisitionState{accounting: &importAccounting{sources: 1}},
|
||||||
|
tier: &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)},
|
||||||
|
sources: sourceInfos(main.source),
|
||||||
|
active: make(map[resourceIdentity]int),
|
||||||
}
|
}
|
||||||
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
|
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry"), 0); len(diagnostics) != 0 {
|
||||||
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +129,11 @@ parent: true
|
|||||||
{path: alphaPath, destination: "/entry/collection"},
|
{path: alphaPath, destination: "/entry/collection"},
|
||||||
{path: betaPath, destination: "/entry/collection"},
|
{path: betaPath, destination: "/entry/collection"},
|
||||||
}
|
}
|
||||||
if len(state.sources) != len(wantSources) {
|
if len(state.sources) != len(wantSources)+1 {
|
||||||
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
|
t.Fatalf("sources = %#v, want %d including main", state.sources, len(wantSources)+1)
|
||||||
}
|
}
|
||||||
for index, want := range wantSources {
|
for index, want := range wantSources {
|
||||||
got := state.sources[index]
|
got := state.sources[index+1]
|
||||||
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
||||||
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
||||||
}
|
}
|
||||||
@@ -160,11 +164,10 @@ func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
|||||||
t.Run("import context failure", func(t *testing.T) {
|
t.Run("import context failure", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||||
input, diagnostics := assembleBaseInputTracked(
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
File(mainPath),
|
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: t.TempDir()}, destination: Keys("mounted")},
|
||||||
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
|
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
)
|
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||||
t.Fatalf("context diagnostics = %#v", diagnostics)
|
t.Fatalf("context diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -177,10 +180,10 @@ func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||||
writeImportFile(t, directory, "broken.yml", "broken: [\n")
|
writeImportFile(t, directory, "broken.yml", "broken: [\n")
|
||||||
input, diagnostics := assembleBaseInputTracked(
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
File(mainPath), Options[map[string]any]{Imports: true},
|
input: loadInput{source: File(mainPath), imports: true, destination: Keys("mounted")},
|
||||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||||
)
|
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
|
||||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -451,7 +454,6 @@ func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: optional.yml
|
- path: optional.yml
|
||||||
optional: true
|
|
||||||
`)
|
`)
|
||||||
loaded, err := LoadLayered(
|
loaded, err := LoadLayered(
|
||||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
||||||
|
|||||||
@@ -198,10 +198,10 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
|||||||
|
|
||||||
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}
|
||||||
}
|
}
|
||||||
options := func(inputs ...Input) LayeredOptions[map[string]any] {
|
options := func(inputs ...Input) LayeredOptions[map[string]any] {
|
||||||
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
|
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
|
||||||
@@ -209,7 +209,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
|||||||
main := item("main", map[string]any{"id": "main", "schema": map[string]any{"version": "v1"}})
|
main := item("main", map[string]any{"id": "main", "schema": map[string]any{"version": "v1"}})
|
||||||
|
|
||||||
t.Run("same assigned main path is protected", func(t *testing.T) {
|
t.Run("same assigned main path is protected", func(t *testing.T) {
|
||||||
_, err := LoadLayered(main, testSecretBinding(), options(item("underlay", map[string]any{"id": "underlay", "schema": nil})))
|
_, err := LoadLayered(main, testSecretBinding(), options(item("underlay", map[string]any{"id": "main", "schema": nil})))
|
||||||
if got := groupedErrorCode(t, err); got != CodeUnderlayVersion {
|
if got := groupedErrorCode(t, err); got != CodeUnderlayVersion {
|
||||||
t.Fatalf("protected item claim code = %s, want %s", got, CodeUnderlayVersion)
|
t.Fatalf("protected item claim code = %s, want %s", got, CodeUnderlayVersion)
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
|||||||
t.Fatalf("unrelated appended peer rejected: %v", err)
|
t.Fatalf("unrelated appended peer rejected: %v", err)
|
||||||
}
|
}
|
||||||
entries := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries").([]any)
|
entries := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries").([]any)
|
||||||
if len(entries) != 1 || entries[0].(map[string]any)["id"] != "main" {
|
if len(entries) != 3 || entries[0].(map[string]any)["id"] != "first" || entries[1].(map[string]any)["id"] != "peer" || entries[2].(map[string]any)["id"] != "main" {
|
||||||
t.Fatalf("main item projection = %#v", entries)
|
t.Fatalf("main item projection = %#v", entries)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
loaded, err := LoadLayered[destinationItemConfig](
|
loaded, err := LoadLayered[destinationItemConfig](
|
||||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||||
testSecretBinding(),
|
testSecretBinding(),
|
||||||
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
|
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
|
||||||
placementMode: placementModeSequenceItem,
|
PlacementMode: PlacementModeSequenceItem,
|
||||||
}}}}},
|
}}}}},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -40,7 +40,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
|||||||
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
|
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved {
|
if len(sources) != 1 || sources[0].placementMode != PlacementModeSequenceItem || sources[0].destinationUnresolved {
|
||||||
t.Fatalf("source placement evidence = %#v", sources)
|
t.Fatalf("source placement evidence = %#v", sources)
|
||||||
}
|
}
|
||||||
if got := sources[0].Destination.String(); got != "/catalog/entries" {
|
if got := sources[0].Destination.String(); got != "/catalog/entries" {
|
||||||
@@ -48,7 +48,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
absent, err := LoadLayered[destinationItemConfig](
|
absent, err := LoadLayered[destinationItemConfig](
|
||||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||||
testSecretBinding(),
|
testSecretBinding(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,17 +64,17 @@ func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
mode placementMode
|
mode PlacementMode
|
||||||
path Path
|
path Path
|
||||||
}{
|
}{
|
||||||
{name: "root item", mode: placementModeSequenceItem},
|
{name: "root item", mode: PlacementModeSequenceItem},
|
||||||
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")},
|
{name: "unknown mode", mode: PlacementMode(255), path: Keys("catalog")},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
reader := bytes.NewBufferString("value: unused\n")
|
reader := bytes.NewBufferString("value: unused\n")
|
||||||
_, err := LoadLayered[destinationItemConfig](
|
_, err := LoadLayered[destinationItemConfig](
|
||||||
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode},
|
Input{Source: Reader("reader", reader), Destination: test.path, PlacementMode: test.mode},
|
||||||
testSecretBinding(),
|
testSecretBinding(),
|
||||||
)
|
)
|
||||||
if err == nil || reader.Len() == 0 {
|
if err == nil || reader.Len() == 0 {
|
||||||
@@ -93,7 +93,7 @@ func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
|
|||||||
|
|
||||||
loaded, err := Load[destinationItemConfig](
|
loaded, err := Load[destinationItemConfig](
|
||||||
Mapping("ordinary", map[string]any{}), testSecretBinding(),
|
Mapping("ordinary", map[string]any{}), testSecretBinding(),
|
||||||
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
Options[destinationItemConfig]{Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
|
explicitPath, explicit, err := placeDocumentWithMode(body, destination, PlacementModeMapping, body.Location, 5, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
|||||||
root := document.Location{SourceID: "item", Line: 1, Column: 1}
|
root := document.Location{SourceID: "item", Line: 1, Column: 1}
|
||||||
body := &document.Node{Kind: document.Mapping, Location: root}
|
body := &document.Node{Kind: document.Mapping, Location: root}
|
||||||
destination := Keys("catalog", "entries")
|
destination := Keys("catalog", "entries")
|
||||||
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6)
|
placedPath, placed, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, 3, 6)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -253,13 +253,13 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
|||||||
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
|
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
if _, _, err := placeDocumentWithMode(body, Path{}, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||||
t.Fatalf("root item placement error = %v, want invalid destination", err)
|
t.Fatalf("root item placement error = %v, want invalid destination", err)
|
||||||
}
|
}
|
||||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||||
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
|
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
|
||||||
}
|
}
|
||||||
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
if _, _, err := placeDocumentWithMode(body, destination, PlacementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||||
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
|
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes)
|
_, _, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, test.depth, test.nodes)
|
||||||
if test.wantAccept && err != nil {
|
if test.wantAccept && err != nil {
|
||||||
t.Fatalf("exact bounds rejected: %v", err)
|
t.Fatalf("exact bounds rejected: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,135 +9,100 @@ import (
|
|||||||
|
|
||||||
const directiveKey = "configkit"
|
const directiveKey = "configkit"
|
||||||
|
|
||||||
type importDeclaration struct {
|
type rootDirectiveContext uint8
|
||||||
path string
|
|
||||||
glob string
|
const (
|
||||||
destination Path
|
rootDirectiveContextImportsOnly rootDirectiveContext = iota + 1
|
||||||
placement placementMode
|
rootDirectiveContextSource
|
||||||
placementPath Path
|
rootDirectiveContextOverlay
|
||||||
placementLocation *Location
|
rootDirectiveContextLayered
|
||||||
optional bool
|
)
|
||||||
location Location
|
|
||||||
|
type rootDirectiveExtraction struct {
|
||||||
|
body *document.Node
|
||||||
|
imports []importDeclarationSpec
|
||||||
|
sequences []sequenceDeclarationSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||||
|
extracted, diagnostics := extractRootDirectives(root, rootDirectiveContextImportsOnly)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, nil, diagnostics
|
||||||
|
}
|
||||||
|
return extracted.body, extracted.imports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRootDirectives(root *document.Node, context rootDirectiveContext) (rootDirectiveExtraction, []Diagnostic) {
|
||||||
|
if root == nil {
|
||||||
|
return rootDirectiveExtraction{}, nil
|
||||||
|
}
|
||||||
body := document.Clone(root)
|
body := document.Clone(root)
|
||||||
var declarations []importDeclaration
|
|
||||||
directiveIndex := -1
|
directiveIndex := -1
|
||||||
for index, entry := range body.Mapping {
|
for index, entry := range body.Mapping {
|
||||||
if entry.Key != directiveKey {
|
if entry.Key != directiveKey {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
directiveIndex = index
|
directiveIndex = index
|
||||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
extracted, diagnostics := parseRootDirectiveBlock(entry.Value, Keys(directiveKey), context)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, nil, diagnostics
|
return rootDirectiveExtraction{}, diagnostics
|
||||||
}
|
|
||||||
declarations = parsed
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
extracted.body = body
|
||||||
if directiveIndex >= 0 {
|
if directiveIndex >= 0 {
|
||||||
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
extracted.body.Mapping = append(extracted.body.Mapping[:directiveIndex], extracted.body.Mapping[directiveIndex+1:]...)
|
||||||
|
}
|
||||||
|
if diagnostics := nestedDirectiveDiagnostics(extracted.body, Path{}); len(diagnostics) != 0 {
|
||||||
|
return rootDirectiveExtraction{}, diagnostics
|
||||||
|
}
|
||||||
|
return extracted, nil
|
||||||
}
|
}
|
||||||
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
||||||
return nil, nil, diagnostics
|
return rootDirectiveExtraction{}, diagnostics
|
||||||
}
|
}
|
||||||
return body, declarations, nil
|
return rootDirectiveExtraction{body: body}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
|
func parseRootDirectiveBlock(node *document.Node, directivePath Path, context rootDirectiveContext) (rootDirectiveExtraction, []Diagnostic) {
|
||||||
if node.Kind != document.Mapping {
|
if node == nil || node.Kind != document.Mapping {
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
return rootDirectiveExtraction{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||||
}
|
}
|
||||||
var declarations []importDeclaration
|
var extracted rootDirectiveExtraction
|
||||||
|
var diagnostics []Diagnostic
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
fieldPath := directivePath.Key(entry.Key)
|
fieldPath := directivePath.Key(entry.Key)
|
||||||
if entry.Key != "imports" {
|
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
|
|
||||||
}
|
|
||||||
if entry.Value.Kind != document.Sequence {
|
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
|
|
||||||
}
|
|
||||||
for index, item := range entry.Value.Sequence {
|
|
||||||
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return nil, diagnostics
|
|
||||||
}
|
|
||||||
declarations = append(declarations, declaration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return declarations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
|
|
||||||
if node.Kind != document.Mapping {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
|
||||||
}
|
|
||||||
declaration := importDeclaration{location: publicLocation(node.Location), placementPath: declarationPath.clone()}
|
|
||||||
seenTarget := false
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
fieldPath := declarationPath.Key(entry.Key)
|
|
||||||
switch entry.Key {
|
switch entry.Key {
|
||||||
case "path", "glob":
|
case "imports":
|
||||||
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
|
if context == rootDirectiveContextOverlay {
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
seenTarget = true
|
if context != rootDirectiveContextImportsOnly && context != rootDirectiveContextSource && context != rootDirectiveContextLayered {
|
||||||
declaration.location = publicLocation(entry.Value.Location)
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "directive extraction context is invalid", entry.Value))
|
||||||
if entry.Key == "path" {
|
continue
|
||||||
declaration.path = entry.Value.Text
|
|
||||||
} else {
|
|
||||||
declaration.glob = entry.Value.Text
|
|
||||||
}
|
}
|
||||||
case "optional":
|
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
|
||||||
if entry.Value.Kind != document.Boolean {
|
extracted.imports = append(extracted.imports, parsed...)
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
diagnostics = append(diagnostics, issues...)
|
||||||
|
case "sequences":
|
||||||
|
if context == rootDirectiveContextImportsOnly {
|
||||||
|
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
declaration.optional = entry.Value.Text == "true"
|
if context != rootDirectiveContextSource && context != rootDirectiveContextOverlay && context != rootDirectiveContextLayered {
|
||||||
case "destination":
|
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "directive extraction context is invalid", entry.Value))
|
||||||
if entry.Value.Kind != document.Sequence {
|
continue
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", entry.Value)}
|
|
||||||
}
|
}
|
||||||
relative := Path{segments: make([]pathSegment, 0, len(entry.Value.Sequence))}
|
parsed, issues := parseSequenceDeclarationDirective(entry.Value, fieldPath)
|
||||||
for index, item := range entry.Value.Sequence {
|
extracted.sequences = append(extracted.sequences, parsed...)
|
||||||
memberPath := fieldPath.Index(index)
|
diagnostics = append(diagnostics, issues...)
|
||||||
if item.Kind != document.String {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)}
|
|
||||||
}
|
|
||||||
member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}}
|
|
||||||
if _, err := normalizeDestination(member); err != nil {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)}
|
|
||||||
}
|
|
||||||
relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text})
|
|
||||||
}
|
|
||||||
normalized, err := normalizeDestination(relative)
|
|
||||||
if err != nil {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", entry.Value)}
|
|
||||||
}
|
|
||||||
declaration.destination = normalized
|
|
||||||
case "placement":
|
|
||||||
declaration.placementPath = fieldPath.clone()
|
|
||||||
placementLocation := publicLocation(entry.Value.Location)
|
|
||||||
declaration.placementLocation = &placementLocation
|
|
||||||
if entry.Value.Kind != document.String {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
|
||||||
}
|
|
||||||
switch entry.Value.Text {
|
|
||||||
case "mapping":
|
|
||||||
declaration.placement = placementModeMapping
|
|
||||||
case "sequence-item":
|
|
||||||
declaration.placement = placementModeSequenceItem
|
|
||||||
default:
|
default:
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
||||||
}
|
|
||||||
default:
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !seenTarget {
|
if len(diagnostics) != 0 {
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
return rootDirectiveExtraction{}, canonicalDiagnostics(diagnostics)
|
||||||
}
|
}
|
||||||
return declaration, nil
|
return extracted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
@@ -197,10 +162,11 @@ func fragmentVersionClaimWithIndex(root *document.Node, rule *VersionRule, sourc
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: currentPath.String()}]; found {
|
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: currentPath.String()}]; found {
|
||||||
if index < assigned.start || index >= assigned.end {
|
projected, ok := assigned.inputForOutput(index)
|
||||||
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
index -= assigned.start
|
index = projected
|
||||||
}
|
}
|
||||||
if index < 0 || index >= len(current.Sequence) {
|
if index < 0 || index >= len(current.Sequence) {
|
||||||
return nil
|
return nil
|
||||||
@@ -249,24 +215,28 @@ func nodeImportDiagnostic(code Code, path Path, message string, node *document.N
|
|||||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||||
}
|
}
|
||||||
|
|
||||||
func overlayDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
func overlayDirectiveDiagnostics(node *document.Node, current Path, allowSequences ...bool) []Diagnostic {
|
||||||
|
sequencesAllowed := len(allowSequences) != 0 && allowSequences[0]
|
||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
childPath := current.Key(entry.Key)
|
childPath := current.Key(entry.Key)
|
||||||
if entry.Key == directiveKey {
|
if entry.Key == directiveKey {
|
||||||
|
if sequencesAllowed && len(current.segments) == 0 && entry.Value != nil && entry.Value.Kind == document.Mapping && len(entry.Value.Mapping) == 1 && entry.Value.Mapping[0].Key == "sequences" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
location := publicLocation(entry.KeyLocation)
|
location := publicLocation(entry.KeyLocation)
|
||||||
return []Diagnostic{{
|
return []Diagnostic{{
|
||||||
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
||||||
Message: "overlays cannot contain configuration directives", Primary: &location,
|
Message: "overlays may contain only root collection sequence declarations", Primary: &location,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath, sequencesAllowed); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if node.Kind == document.Sequence {
|
} else if node.Kind == document.Sequence {
|
||||||
for index, child := range node.Sequence {
|
for index, child := range node.Sequence {
|
||||||
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index), sequencesAllowed); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,3 +265,72 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
|||||||
}
|
}
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disabledRootImportDiagnostics(node *document.Node) []Diagnostic {
|
||||||
|
if node == nil || node.Kind != document.Mapping {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rootDirective *document.Node
|
||||||
|
var rootLocation document.Location
|
||||||
|
var foundRootDirective bool
|
||||||
|
for index := range node.Mapping {
|
||||||
|
if node.Mapping[index].Key == directiveKey {
|
||||||
|
rootDirective = node.Mapping[index].Value
|
||||||
|
rootLocation = node.Mapping[index].KeyLocation
|
||||||
|
foundRootDirective = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundRootDirective {
|
||||||
|
if rootDirective == nil || rootDirective.Kind != document.Mapping {
|
||||||
|
return disabledDirectiveDiagnostic(rootLocation)
|
||||||
|
}
|
||||||
|
for _, directive := range rootDirective.Mapping {
|
||||||
|
if directive.Key == "imports" {
|
||||||
|
return disabledDirectiveDiagnostic(rootLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key == directiveKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diagnostic := disabledNestedDirectiveDiagnostics(entry.Value, Path{}.Key(entry.Key)); len(diagnostic) != 0 {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disabledNestedDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
childPath := path.Key(entry.Key)
|
||||||
|
if entry.Key == directiveKey {
|
||||||
|
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
||||||
|
Message: "configuration directives require imports to be enabled", Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if diagnostics := disabledNestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
if diagnostics := disabledNestedDirectiveDiagnostics(child, path.Index(index)); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disabledDirectiveDiagnostic(location document.Location) []Diagnostic {
|
||||||
|
public := Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeImportDisabled, Severity: SeverityError, Path: Keys(directiveKey),
|
||||||
|
Message: "configuration directives require imports to be enabled", Primary: &public,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|||||||
353
internal/engine/directory_links_test.go
Normal file
353
internal/engine/directory_links_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectDirectoriesApplyHiddenAndFollowControls(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"direct.yaml": "value: direct\n",
|
||||||
|
".private.yaml": "value: hidden\n",
|
||||||
|
".private/nested.yaml": "value: hidden directory\n",
|
||||||
|
"target/linked.yaml": "value: linked\n",
|
||||||
|
"target/source.txt": "value: source\n",
|
||||||
|
} {
|
||||||
|
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Symlink("target", filepath.Join(root, "visible-alias")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("target/linked.yaml", filepath.Join(root, "linked-alias.txt")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("target/source.txt", filepath.Join(root, "selected.yaml")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("target", filepath.Join(root, ".hidden-alias")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: false
|
||||||
|
hidden: false
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if got, want := selectedPathsRelativeTo(selection, root), []string{"direct.yaml", "target/linked.yaml"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("filtered paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
selection = selectDirectoryWithOptions(t, access, root, ` - dir: visible-alias
|
||||||
|
follow: false
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible-alias/linked.yaml"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("explicit symlink root paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
selection = selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
hidden: false
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if got, want := selectedPathsRelativeTo(selection, root), []string{"direct.yaml", "selected.yaml", "target/linked.yaml", "visible-alias/linked.yaml"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("followed paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
selection = selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
hidden: true
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if got, want := selectedPathsRelativeTo(selection, root), []string{
|
||||||
|
".hidden-alias/linked.yaml", ".private.yaml", ".private/nested.yaml", "direct.yaml",
|
||||||
|
"selected.yaml", "target/linked.yaml", "visible-alias/linked.yaml",
|
||||||
|
}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("hidden-enabled paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDirectoriesRejectEscapingAndCyclicLinks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("escape", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "outside.yaml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
_, diagnostics := selectDirectoryWithDiagnostics(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||||
|
t.Fatalf("escape diagnostics = %#v, want import.outside_root", diagnostics)
|
||||||
|
}
|
||||||
|
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: false
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if len(selection.candidates) != 0 {
|
||||||
|
t.Fatalf("follow:false selected an escaping symlink: %#v", selection.candidates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{name: "self"},
|
||||||
|
{name: "ancestor"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if test.name == "self" {
|
||||||
|
if err := os.Symlink(".", filepath.Join(root, "loop")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("..", filepath.Join(root, "nested", "up")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
_, diagnostics := selectDirectoryWithDiagnostics(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
`)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportCycle {
|
||||||
|
t.Fatalf("cycle diagnostics = %#v, want import.cycle", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Message != "directory traversal cycle detected" {
|
||||||
|
t.Fatalf("cycle diagnostic exposes unexpected detail: %q", diagnostics[0].Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDirectoriesRetainSeparateNoncyclicAliases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "target"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "target", "item.yaml"), []byte("value: item\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, alias := range []string{"first", "second"} {
|
||||||
|
if err := os.Symlink("target", filepath.Join(root, alias)); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
want := []string{"first/item.yaml", "second/item.yaml", "target/item.yaml"}
|
||||||
|
if got := selectedPathsRelativeTo(selection, root); !equalStrings(got, want) {
|
||||||
|
t.Fatalf("alias candidates = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDirectoriesUseNativeReadLinkCapability(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := &linkAwareMapFS{
|
||||||
|
files: fstest.MapFS{
|
||||||
|
"real": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||||
|
"real/item.yaml": &fstest.MapFile{Data: []byte("value: item\n")},
|
||||||
|
"alias": &fstest.MapFile{Mode: fs.ModeSymlink, Data: []byte("real")},
|
||||||
|
".hidden": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||||
|
".hidden/secret.yaml": &fstest.MapFile{Data: []byte("value: secret\n")},
|
||||||
|
},
|
||||||
|
links: map[string]string{"alias": "real"},
|
||||||
|
}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
selection := selectDirectoryWithOptions(t, access, ".", ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
hidden: false
|
||||||
|
pattern: '*.yaml'
|
||||||
|
`)
|
||||||
|
if got, want := selectedPaths(selection), []string{"alias/item.yaml", "real/item.yaml"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("native link candidates = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
cyclic := &linkAwareMapFS{
|
||||||
|
files: fstest.MapFS{
|
||||||
|
"loop": &fstest.MapFile{Mode: fs.ModeSymlink, Data: []byte(".")},
|
||||||
|
},
|
||||||
|
links: map[string]string{"loop": "."},
|
||||||
|
}
|
||||||
|
_, diagnostics := selectDirectoryWithDiagnostics(t, newNativeAccess(cyclic, ".", &namespaceToken{}), ".", ` - dir: .
|
||||||
|
recursive: true
|
||||||
|
follow: true
|
||||||
|
`)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportCycle {
|
||||||
|
t.Fatalf("native loop diagnostics = %#v, want import.cycle", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := newNativeAccess(fstest.MapFS{"plain.yaml": &fstest.MapFile{Data: []byte("value: plain\n")}}, ".", &namespaceToken{})
|
||||||
|
selection = selectDirectoryWithOptions(t, plain, ".", " - dir: .\n")
|
||||||
|
if got, want := selectedPaths(selection), []string{"plain.yaml"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("plain native filesystem selection = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDirectoryWithOptions(t *testing.T, access *filesystemAccess, base, declaration string) importSelection {
|
||||||
|
t.Helper()
|
||||||
|
selection, diagnostics := selectDirectoryWithDiagnostics(t, access, base, declaration)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("directory selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return selection
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base, declaration string) (importSelection, []Diagnostic) {
|
||||||
|
t.Helper()
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, declaration)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return selectDirectoryImportCandidates(access, base, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedPaths(selection importSelection) []string {
|
||||||
|
paths := make([]string, 0, len(selection.candidates))
|
||||||
|
for _, candidate := range selection.candidates {
|
||||||
|
paths = append(paths, candidate.path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedPathsRelativeTo(selection importSelection, root string) []string {
|
||||||
|
paths := make([]string, 0, len(selection.candidates))
|
||||||
|
for _, candidate := range selection.candidates {
|
||||||
|
relative, err := filepath.Rel(root, candidate.path)
|
||||||
|
if err != nil {
|
||||||
|
paths = append(paths, candidate.path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths = append(paths, filepath.ToSlash(relative))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(left, right []string) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range left {
|
||||||
|
if left[index] != right[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkAwareMapFS struct {
|
||||||
|
files fstest.MapFS
|
||||||
|
links map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *linkAwareMapFS) Open(name string) (fs.File, error) {
|
||||||
|
resolved, err := f.resolve(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.files.Open(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *linkAwareMapFS) ReadLink(name string) (string, error) {
|
||||||
|
target, ok := f.links[name]
|
||||||
|
if !ok {
|
||||||
|
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid}
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *linkAwareMapFS) Lstat(name string) (fs.FileInfo, error) {
|
||||||
|
if _, ok := f.links[name]; ok {
|
||||||
|
return linkMapFileInfo{name: path.Base(name)}, nil
|
||||||
|
}
|
||||||
|
return fs.Stat(f.files, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *linkAwareMapFS) resolve(name string) (string, error) {
|
||||||
|
if !fs.ValidPath(name) {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
resolved := ""
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, segment := range strings.Split(name, "/") {
|
||||||
|
candidate := path.Join(resolved, segment)
|
||||||
|
if target, ok := f.links[candidate]; ok {
|
||||||
|
if _, exists := seen[candidate]; exists {
|
||||||
|
return "", fs.ErrInvalid
|
||||||
|
}
|
||||||
|
seen[candidate] = struct{}{}
|
||||||
|
candidate = path.Clean(path.Join(path.Dir(candidate), target))
|
||||||
|
}
|
||||||
|
resolved = candidate
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkMapFileInfo struct{ name string }
|
||||||
|
|
||||||
|
func (i linkMapFileInfo) Name() string { return i.name }
|
||||||
|
func (linkMapFileInfo) Size() int64 { return 0 }
|
||||||
|
func (linkMapFileInfo) Mode() fs.FileMode { return fs.ModeSymlink | 0o777 }
|
||||||
|
func (linkMapFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (linkMapFileInfo) IsDir() bool { return false }
|
||||||
|
func (linkMapFileInfo) Sys() any { return nil }
|
||||||
371
internal/engine/directory_selection_test.go
Normal file
371
internal/engine/directory_selection_test.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := fstest.MapFS{
|
||||||
|
"one": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||||
|
"one/a.yaml": &fstest.MapFile{Data: []byte("a: 1\n")},
|
||||||
|
"one/README": &fstest.MapFile{Data: []byte("readme\n")},
|
||||||
|
"one/.hidden": &fstest.MapFile{Data: []byte("hidden\n")},
|
||||||
|
"one/draft.draft.yaml": &fstest.MapFile{Data: []byte("draft\n")},
|
||||||
|
"one/nested/deep.yaml": &fstest.MapFile{Data: []byte("deep: 1\n")},
|
||||||
|
"two": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||||
|
"two/a.yaml": &fstest.MapFile{Data: []byte("a: 2\n")},
|
||||||
|
"three": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||||
|
"three/extensionless": &fstest.MapFile{Data: []byte("plain\n")},
|
||||||
|
}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, `
|
||||||
|
- dir: [one, one, missing, two]
|
||||||
|
pattern: ['*.yaml', 'a.*', 'README']
|
||||||
|
exclude: '*.draft.yaml'
|
||||||
|
required: true
|
||||||
|
`)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
selection, diagnostics := selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), accounting)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(selection.candidates) != 3 {
|
||||||
|
t.Fatalf("selection = %#v, want three unioned matches", selection)
|
||||||
|
}
|
||||||
|
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
|
||||||
|
t.Fatalf("selected paths = %#v", selection.candidates)
|
||||||
|
}
|
||||||
|
if accounting.entries != 6 {
|
||||||
|
t.Fatalf("entry visits = %d, want 6 entries across distinct roots", accounting.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"a.yaml": "a: 1\n",
|
||||||
|
".private": "private\n",
|
||||||
|
"README": "readme\n",
|
||||||
|
"nested/deep": "deep\n",
|
||||||
|
} {
|
||||||
|
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(value), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - dir: .\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(selection.candidates) != 3 {
|
||||||
|
t.Fatalf("selected files = %#v, want yaml, hidden, and extensionless files", selection.candidates)
|
||||||
|
}
|
||||||
|
for _, candidate := range selection.candidates {
|
||||||
|
if filepath.Base(candidate.path) == "deep" {
|
||||||
|
t.Fatalf("shallow search descended into a child directory: %#v", selection.candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := fstest.MapFS{
|
||||||
|
"available": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||||
|
"available/match.yaml": &fstest.MapFile{Data: []byte("match: true\n")},
|
||||||
|
"available/excluded.yaml": &fstest.MapFile{Data: []byte("excluded: true\n")},
|
||||||
|
}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
wantError bool
|
||||||
|
wantCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing root alongside a match",
|
||||||
|
yaml: " - dir: [missing, available]\n pattern: '*.yaml'\n exclude: excluded.yaml\n required: true\n",
|
||||||
|
wantCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all matches excluded",
|
||||||
|
yaml: " - dir: available\n pattern: '*.yaml'\n exclude: '*.yaml'\n required: true\n",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit empty patterns select nothing",
|
||||||
|
yaml: " - dir: available\n pattern: []\n",
|
||||||
|
wantCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty search roots",
|
||||||
|
yaml: " - dir: []\n required: true\n",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, test.yaml)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
if test.wantError {
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("diagnostics = %#v, want required empty-union error", diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 || len(selection.candidates) != test.wantCount {
|
||||||
|
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
|
}
|
||||||
|
if test.wantCount == 1 && selection.candidates[0].path != "available/match.yaml" {
|
||||||
|
t.Fatalf("selected path = %q, want available/match.yaml", selection.candidates[0].path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := fstest.MapFS{
|
||||||
|
"file.yaml": &fstest.MapFile{Data: []byte("not a directory\n")},
|
||||||
|
}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - dir: file.yaml\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid {
|
||||||
|
t.Fatalf("invalid-root diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
denied := &statOnlyImportFilesystem{statErrors: map[string]error{"denied": fs.ErrPermission}}
|
||||||
|
access = newNativeAccess(denied, ".", &namespaceToken{})
|
||||||
|
specs, diagnostics = parseImportSpecsYAML(t, " - dir: denied\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid {
|
||||||
|
t.Fatalf("inaccessible-root diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "welcome.yaml"), []byte("welcome: true\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("welcome.yaml", filepath.Join(root, "welcome.draft.yaml")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - dir: .\n pattern: '*.yaml'\n exclude: '*.draft.yaml'\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 0 || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "welcome.yaml" {
|
||||||
|
t.Fatalf("alias filtering selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
for name := range map[string]struct{}{
|
||||||
|
"direct.yaml": {},
|
||||||
|
"child/child.yaml": {},
|
||||||
|
"child/grand/grand.yaml": {},
|
||||||
|
} {
|
||||||
|
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte("value: true\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
defaults ImportDefaults
|
||||||
|
wantPaths []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "depth one includes direct files",
|
||||||
|
yaml: " - dir: .\n recursive: true\n depth: 1\n pattern: '*.yaml'\n",
|
||||||
|
wantPaths: []string{"direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "depth two includes one descendant level",
|
||||||
|
yaml: " - dir: .\n recursive: true\n depth: 2\n pattern: '*.yaml'\n",
|
||||||
|
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero depth is unlimited",
|
||||||
|
yaml: " - dir: .\n recursive: true\n depth: 0\n pattern: '*.yaml'\n",
|
||||||
|
wantPaths: []string{"child.yaml", "grand.yaml", "direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recursion stays disabled by default",
|
||||||
|
yaml: " - dir: .\n pattern: '*.yaml'\n",
|
||||||
|
wantPaths: []string{"direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit false overrides application recursion",
|
||||||
|
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
|
||||||
|
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||||
|
wantPaths: []string{"direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "application default enables recursion for depth",
|
||||||
|
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
|
||||||
|
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||||
|
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large depth remains a valid unlimited traversal bound",
|
||||||
|
yaml: " - dir: .\n recursive: true\n depth: 999999999999999999999999999999\n pattern: '*.yaml'\n",
|
||||||
|
wantPaths: []string{"child.yaml", "grand.yaml", "direct.yaml"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filename filter does not prune directories",
|
||||||
|
yaml: " - dir: .\n recursive: true\n depth: 3\n pattern: grand.yaml\n",
|
||||||
|
wantPaths: []string{"grand.yaml"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, test.yaml)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], test.defaults)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, candidate := range selection.candidates {
|
||||||
|
got = append(got, filepath.Base(candidate.path))
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(test.wantPaths, ",") {
|
||||||
|
t.Fatalf("selected basenames = %#v, want %#v", got, test.wantPaths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, name := range []string{"direct.yaml", "child/child.yaml", "child/grand/grand.yaml"} {
|
||||||
|
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte("value: true\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - dir: .\n recursive: true\n pattern: []\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
|
||||||
|
if len(diagnostics) != 0 || len(selection.candidates) != 0 {
|
||||||
|
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
|
}
|
||||||
|
if accounting.entries != 5 {
|
||||||
|
t.Fatalf("empty search visited %d entries, want all 5 within the recursive search", accounting.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
limited := defaultImportLimits()
|
||||||
|
limited.entries = 2
|
||||||
|
_, diagnostics = selectDirectoryImportCandidates(access, root, specs[0], policy, limited, &importAccounting{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
|
t.Fatalf("directory limit diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPointer(value bool) *bool { return &value }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user