Compare commits
127 Commits
d0a605afbb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ec66e867 | |||
| be5cdeb502 | |||
| 1f64673d0d | |||
| 01b08e76a3 | |||
| 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 |
@@ -45,6 +45,11 @@ ConfigKit does not search conventional application paths implicitly. See the
|
|||||||
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||||
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
[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 {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
|
|||||||
@@ -52,16 +52,6 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
|||||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
|
|
||||||
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -76,15 +66,14 @@ func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []Impo
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
|
func toEngineOverrides(values []Override) []engine.Override {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
result := make([]engine.ImportSelectionForFacade, len(values))
|
result := make([]engine.Override, len(values))
|
||||||
for index, value := range values {
|
for index, value := range values {
|
||||||
result[index] = engine.ImportSelectionForFacade{
|
result[index] = engine.Override{
|
||||||
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
|
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
|
||||||
Selector: string(value.Selector), Outcome: string(value.Outcome),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -92,31 +81,14 @@ func toEngineImportSelections(values []ImportSelection) []engine.ImportSelection
|
|||||||
|
|
||||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||||
return engine.ImportDefaults{
|
return engine.ImportDefaults{
|
||||||
Required: cloneImportDefaultBool(value.Required),
|
Required: value.Required,
|
||||||
Follow: cloneImportDefaultBool(value.Follow),
|
Follow: value.Follow,
|
||||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
Hidden: value.Hidden,
|
||||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
Recursive: value.Recursive,
|
||||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
Pattern: value.Pattern,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneImportDefaultBool(value *bool) *bool {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *value
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneImportDefaultStrings(values []string) []string {
|
|
||||||
if values == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make([]string, len(values))
|
|
||||||
copy(cloned, values)
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -127,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 {
|
||||||
@@ -150,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...)}
|
||||||
|
}
|
||||||
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/`.
|
||||||
@@ -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
|
||||||
@@ -78,9 +152,14 @@ 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. Ordinary `Load` recognizes
|
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
||||||
source-root collection declarations whether or not imports are enabled; an
|
source-root collection declarations whether or not imports are enabled; an
|
||||||
`imports` declaration still requires imports to be enabled. `NoSource` rejects
|
`imports` declaration still requires imports to be enabled. `NoSource` may enable
|
||||||
imports, active overlays, and filesystem context as invalid combinations but
|
imports, but it has no main declarations to traverse. It accepts an explicit
|
||||||
accepts overrides and remains an absent source rather than an empty document.
|
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
|
||||||
@@ -149,7 +228,8 @@ 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
|
||||||
@@ -969,6 +1049,12 @@ 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
@@ -55,6 +79,21 @@ 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
|
## Import defaults and selection inspection
|
||||||
|
|
||||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||||
|
|||||||
@@ -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), [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) |
|
||||||
@@ -63,6 +63,7 @@ Accepted decisions:
|
|||||||
- [Separate import selection controls](adr/0016-independent-import-controls.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)
|
- [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)
|
- [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
|
||||||
@@ -112,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
|
||||||
@@ -167,7 +168,46 @@ The subsequent import-integration audit and completed remediation record are
|
|||||||
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||||
`git show 6903c9e:docs/roadmap/remediation.md`.
|
`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 completed audit remediation's scope is recorded in the
|
||||||
|
[remediation roadmap](roadmap/roadmap.md). The consolidated audit, including
|
||||||
|
findings, evidence, and settled scope decisions, is preserved at
|
||||||
|
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
|
||||||
|
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 453da68:docs/roadmap/audit.md
|
||||||
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -215,16 +255,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` |
|
| 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'` |
|
||||||
| 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` |
|
| 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 | [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'` |
|
| 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'` |
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ This note records implementation flow and private identities for maintainers.
|
|||||||
## Discovery and acquisition
|
## Discovery and acquisition
|
||||||
|
|
||||||
Each source's root directive is parsed into presence-aware declarations and
|
Each source's root directive is parsed into presence-aware declarations and
|
||||||
resolved against load-wide defaults. Candidate selection preserves a normalized
|
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
|
namespace path separately from canonical access identity: overlapping roots
|
||||||
coalesce the same candidate spelling in that namespace, while aliases reached
|
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||||
through distinct spellings retain separate inclusion routes for admission.
|
through distinct spellings retain separate inclusion routes for admission.
|
||||||
@@ -20,10 +24,34 @@ 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
|
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
|
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.
|
not mistaken for one. This is separate from import dependency-cycle detection.
|
||||||
For host sources, discovery uses rooted `Lstat` and canonical containment. Native
|
For host sources, discovery uses rooted `Lstat` and canonical containment.
|
||||||
sources use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows
|
`filesystem_context.go` owns resolved access facts and live access setup; the
|
||||||
the capabilities and behavior of the provided `fs.FS`, without claiming a
|
acquisition operation closes its live handles before composition. Native sources
|
||||||
general sandbox guarantee.
|
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
|
Acquisition remains authored: declarations are visited in source order and each
|
||||||
selected candidate set in namespace-path lexical order. This preserves source
|
selected candidate set in namespace-path lexical order. This preserves source
|
||||||
@@ -64,6 +92,12 @@ 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
|
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||||
reported as cycle members.
|
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
|
Import composition schedules a main tier or one underlay group as a single
|
||||||
strict-scope graph. Applicable collection declarations are gathered for the
|
strict-scope graph. Applicable collection declarations are gathered for the
|
||||||
scope before peer composition, including identity inherited from lower tiers.
|
scope before peer composition, including identity inherited from lower tiers.
|
||||||
@@ -78,10 +112,14 @@ mapping order in its output.
|
|||||||
These scopes do not flatten into one global graph, and independent precedence
|
These scopes do not flatten into one global graph, and independent precedence
|
||||||
tiers remain separate.
|
tiers remain separate.
|
||||||
|
|
||||||
The main owners are [`import_selection.go`](../../internal/engine/import_selection.go),
|
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),
|
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||||
[`import_graph.go`](../../internal/engine/import_graph.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
|
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
|
||||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
||||||
Their focused behavior is covered by import-selection, scheduler, and scheduling
|
Their focused behavior is covered by import-selection, acquisition, scheduler,
|
||||||
integration tests under `internal/engine`.
|
and scheduling integration tests under `internal/engine`; see the
|
||||||
|
[development test map](../development.md#task-to-test-map).
|
||||||
|
|||||||
@@ -7,33 +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, YAML collection declarations and operations for
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
identified sequences, and application identity registrations for strict
|
follows the existing composition, metadata, and resource-context lifecycle.
|
||||||
|
It also owns grouped strict composition, recursive precedence assembly,
|
||||||
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
composition, inheritance, and final validation. It applies the selected
|
composition, inheritance, and final validation. It applies the selected
|
||||||
collection operation at precedence boundaries and owns dependency-ready,
|
collection operation at precedence boundaries and owns dependency-ready,
|
||||||
destination-local inheritance before typed decoding. CLI assignment follows
|
destination-local inheritance before typed decoding. CLI assignment follows
|
||||||
inheritance and decoding, before application resolution. The engine also owns
|
inheritance and decoding, before application resolution. The engine also owns
|
||||||
shared source-version checking with sanitized declaration evidence.
|
shared source-version checking with sanitized declaration evidence.
|
||||||
Explicit import discovery supports declared paths, directory searches, and
|
Explicit import discovery supports declared paths, directory searches, and
|
||||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
globs; root-owned defaults are converted to the engine's single defaults input
|
||||||
root facade without changing acquisition order. Import and inheritance
|
and snapshotted during load preflight before acquisition, while source-local
|
||||||
resolution share dependency-ready scheduling within their composition scopes.
|
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.
|
||||||
@@ -53,7 +61,9 @@ 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)
|
||||||
@@ -78,5 +88,73 @@ records the shared dependency-ready ordering decision; [ADR
|
|||||||
0016](../adr/0016-independent-import-controls.md) records the independent
|
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||||
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
||||||
operations and CLI ordering. The [import internals](imports.md) document candidate and
|
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
namespace identities, discovery and scheduling, and the bounded algorithms.
|
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.
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ remains in the roadmap until implemented.
|
|||||||
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
|
||||||
@@ -157,11 +161,13 @@ 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:
|
Import discovery is available only through declarations in selected sources:
|
||||||
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||||
ConfigKit does not search conventional application paths or discover a main
|
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
|
configuration file implicitly. Imports and sibling inheritance share
|
||||||
dependency-ready scheduling within their existing scopes; acquisition order and
|
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||||
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# Internal Complexity Refactoring Roadmap
|
|
||||||
|
|
||||||
Status: proposed target state. This roadmap defines a behavior-preserving
|
|
||||||
refactor; a later implementation plan will define stages and exact private
|
|
||||||
signatures and file names.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Make ConfigKit's import, inheritance, and projection implementation easier to
|
|
||||||
understand, review, and change while preserving its public APIs and configuration
|
|
||||||
behavior. Concentrate on functions that coordinate several responsibilities and
|
|
||||||
carry substantial shared state.
|
|
||||||
|
|
||||||
The target is clearer responsibility and ownership. Adding a few well-named
|
|
||||||
helpers, private records, or focused files can serve that target even when total
|
|
||||||
line count increases. Complexity measurements help locate candidates; they do
|
|
||||||
not establish function-size limits, score thresholds, or release gates.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns package and ownership
|
|
||||||
constraints. The [configuration contract](../config.md) and exported Go comments
|
|
||||||
remain authoritative for consumer behavior.
|
|
||||||
|
|
||||||
## Current Baseline
|
|
||||||
|
|
||||||
Reviewed baseline: `7a83704f43ef9870db33661231cdff5d6ee502f7`.
|
|
||||||
|
|
||||||
The root facade delegates to a cohesive private engine, supported by the ordered
|
|
||||||
document representation and YAML adapter. Most functions are small. The
|
|
||||||
complexity audit identified these concentrated maintenance costs:
|
|
||||||
|
|
||||||
| Area | Current implementation | Required improvement |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Imports | `imports.go` spans acquisition, item-local composition, and evidence projection. Private loading variants pass long positional argument lists. Source admission and directory selection contain substantial mixed control flow. | Give import entry points explicit, cohesive inputs and separate the responsibilities of acquisition, scope assembly, and evidence projection. |
|
|
||||||
| Inheritance | `applyInheritanceScopeTemplates` coordinates payload preparation, parent combination, policy ownership, origins, history, and accounting in one executor. | Keep one readable executor with focused operations and explicit state handoffs. |
|
|
||||||
| Projection | `valueOperation.project` combines type dispatch with struct flattening, map and sequence traversal, scalar conversion, sensitivity, and accounting. | Retain clear dispatch while moving substantial value-kind handling into focused helpers. |
|
|
||||||
|
|
||||||
The implemented inventory belongs to the
|
|
||||||
[component overview](../internal/overview.md). Existing import flow and scheduling
|
|
||||||
are described in [import internals](../internal/imports.md).
|
|
||||||
|
|
||||||
## Target Architecture
|
|
||||||
|
|
||||||
The refactor stays within `internal/engine`. The root facade, `internal/document`,
|
|
||||||
and `internal/yaml` retain their existing responsibilities and dependency
|
|
||||||
direction. Focused files and ordinary private functions express the new internal
|
|
||||||
organization. Package extraction is outside this roadmap's selected design.
|
|
||||||
|
|
||||||
`executeLoadRequest` remains the visible coordinator of the shared load lifecycle.
|
|
||||||
Its phase order should remain easy to follow. Helpers expose meaningful work and
|
|
||||||
results rather than distributing that order across a generic execution framework.
|
|
||||||
|
|
||||||
Existing shared mechanisms remain shared: recursive mapping combination,
|
|
||||||
identified-sequence operations, dependency-ready scheduling, schema descriptors,
|
|
||||||
and projection/accounting state. New helper boundaries make their ownership and
|
|
||||||
use clearer without creating parallel implementations.
|
|
||||||
|
|
||||||
### Imports: Cohesive Inputs And Scope Ownership
|
|
||||||
|
|
||||||
Import loading has a clear private entry surface using named request/context
|
|
||||||
fields for related inputs. Reuse the existing load-request, acquisition-scope,
|
|
||||||
and traversal models where they fit. Consolidate forwarding variants whose only
|
|
||||||
purpose is passing another optional positional argument through the chain.
|
|
||||||
Retain convenience helpers when they express a useful, distinct operation.
|
|
||||||
|
|
||||||
Keep these state lifetimes identifiable:
|
|
||||||
|
|
||||||
- Load-wide state: accounting, shared inclusion/repetition information,
|
|
||||||
namespaces, and source-version processing.
|
|
||||||
- Acquisition requests: source and filesystem context, placement, declarations,
|
|
||||||
applicable policy, and the relevant assembly handoff.
|
|
||||||
- Composition scopes: peer contributions, item-local helpers, record ownership,
|
|
||||||
and pending declarations.
|
|
||||||
- Evidence: physical source records, effective destinations, origins, and retained
|
|
||||||
history projections.
|
|
||||||
|
|
||||||
Group acquisition, pending-item assembly, and evidence projection in focused
|
|
||||||
files. A caller should be able to identify which operation acquires resources,
|
|
||||||
which composes already acquired content, and which projects retained evidence.
|
|
||||||
Shared mutable state has a visible owner; a request record need not duplicate
|
|
||||||
state already held by that owner.
|
|
||||||
|
|
||||||
Within dense admission, directory-selection, and declaration-parsing routines,
|
|
||||||
extract coherent operations where that reduces nested, mixed responsibilities.
|
|
||||||
Preserve the existing selection algorithms and host/native filesystem behavior.
|
|
||||||
Resource ownership and closure remain explicit across success and failure paths.
|
|
||||||
|
|
||||||
The resulting flow preserves the distinction among acquisition order, strict
|
|
||||||
scope scheduling, and precedence. Completing a helper scope before placing its
|
|
||||||
item remains part of the same import flow. Ordinary and layered loading continue
|
|
||||||
to share the applicable mechanisms.
|
|
||||||
|
|
||||||
### Inheritance: One Executor With Explicit Phases
|
|
||||||
|
|
||||||
Keep the mapping and identified-sequence adapters and their shared template
|
|
||||||
executor. The executor coordinates focused operations for:
|
|
||||||
|
|
||||||
- Preparing child payload, directives, and destination-local state.
|
|
||||||
- Preparing the resolved parent payload and applicable inherited policy.
|
|
||||||
- Combining values through the existing mapping and sequence mechanisms.
|
|
||||||
- Producing policy, origin, replacement, and replay evidence from that combination.
|
|
||||||
- Accounting for expansion and retained history under the existing budgets.
|
|
||||||
|
|
||||||
These are responsibility boundaries, not a required helper count or a new
|
|
||||||
scheduler. Small private result records are appropriate when related outputs
|
|
||||||
otherwise travel through long argument lists or implicit mutations.
|
|
||||||
|
|
||||||
Authored templates, resolved values, scoped policy, and inspection evidence remain
|
|
||||||
distinguishable. Immutable borrowed inputs and owned mutable results have clear
|
|
||||||
lifetimes. Nested instructions remain available for destination-local evaluation.
|
|
||||||
For identified-record inheritance, the child's own identity fields and origins
|
|
||||||
remain intact; the parent's identity fields are excluded from inherited payload
|
|
||||||
and its history, as the current contract requires.
|
|
||||||
|
|
||||||
One sequence-operation result continues to drive value construction, record
|
|
||||||
ownership, origins, and historical position projection. Helpers consume that
|
|
||||||
evidence directly. Scheduling remains dependency-ready, enclosing relationships
|
|
||||||
resolve before nested ones, and assembled sequence order remains unchanged.
|
|
||||||
|
|
||||||
### Projection: Explicit Value-Kind Handlers
|
|
||||||
|
|
||||||
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
|
|
||||||
the owner of per-operation traversal and accounting state. The central dispatch
|
|
||||||
clearly selects the appropriate handling for pointers, dynamic values, structs,
|
|
||||||
maps, sequences, and scalars.
|
|
||||||
|
|
||||||
Substantial struct, map, and sequence work lives in focused handlers. Struct
|
|
||||||
projection retains declaration order, inline flattening, collision diagnostics,
|
|
||||||
and sensitivity propagation. Map projection retains deterministic key ordering.
|
|
||||||
Scalar conversion retains the existing number, duration, text-codec, and secret
|
|
||||||
reference representations.
|
|
||||||
|
|
||||||
Depth and node accounting retain their existing meaning, including flattened
|
|
||||||
containers. Cycle tracking stays active through recursive child traversal and
|
|
||||||
is balanced on error paths as well as success. Passing state through a helper
|
|
||||||
does not introduce another full-tree projection or an unnecessary ownership copy.
|
|
||||||
|
|
||||||
Cloning and source-value construction remain distinct operations with their
|
|
||||||
existing semantics. Reuse small helpers when the semantics coincide; keep the
|
|
||||||
projection refactor centered on making the existing traversal readable.
|
|
||||||
|
|
||||||
## Behavior And Scope Preservation
|
|
||||||
|
|
||||||
The implementation preserves:
|
|
||||||
|
|
||||||
- Root-owned public types, signatures, supported import paths, and facade boundaries.
|
|
||||||
- Supported source forms, opt-ins, import controls, placement, acquisition and
|
|
||||||
composition ordering, and source/version evidence.
|
|
||||||
- Strict peer composition, precedence operations, identity agreement and lifetime,
|
|
||||||
record ownership, and destination-local inheritance.
|
|
||||||
- The load lifecycle, including typed defaults, post-decode CLI assignment,
|
|
||||||
recorded application resolution, and final validation.
|
|
||||||
- Explicit presence, provenance, history, sensitivity, resource contexts, and
|
|
||||||
detached publication/query results.
|
|
||||||
- Established diagnostic codes, semantic paths, source coordinates, ordering,
|
|
||||||
resource lifetime, and existing limit behavior.
|
|
||||||
|
|
||||||
The owning contracts define the extent of these obligations. Incidental private
|
|
||||||
call sequences, exact helper names, and allocation counts do not acquire new
|
|
||||||
guarantees through this roadmap. The work adds no consumer options, configuration
|
|
||||||
syntax, dependencies, or complexity-based gates.
|
|
||||||
|
|
||||||
## Relationship To Remediation And Other Work
|
|
||||||
|
|
||||||
[Remediation](remediation.md) owns the identified correctness and algorithmic
|
|
||||||
efficiency findings. Several overlap these files, particularly item-scope
|
|
||||||
application matching and inheritance policy localization.
|
|
||||||
|
|
||||||
Use the documented contract and completed remediation as the behavioral baseline.
|
|
||||||
Keep corrective changes reviewable under their findings, and retain their
|
|
||||||
regressions through this refactor. The later implementation plan should coordinate
|
|
||||||
overlapping work and select its starting revision rather than duplicating the
|
|
||||||
remediation plan here. These structural changes do not close a finding merely
|
|
||||||
because its function moved or became smaller.
|
|
||||||
|
|
||||||
The pending [discovery roadmap](discovery.md) remains separate feature work.
|
|
||||||
Import cleanup should leave clear private boundaries for future use without
|
|
||||||
implementing discovery behavior as part of this refactor.
|
|
||||||
|
|
||||||
## Work Needed And Validation
|
|
||||||
|
|
||||||
Implementation needs to consolidate import inputs and adapters, separate import
|
|
||||||
responsibilities, extract inheritance operations with explicit outputs, and
|
|
||||||
decompose projection handling. Remove superseded private plumbing as its callers
|
|
||||||
migrate. Review the resulting call flow for repeated traversals, policy scans,
|
|
||||||
copies, or hidden state transitions introduced by extraction.
|
|
||||||
|
|
||||||
Reuse the existing behavioral suites under the
|
|
||||||
[development guide](../development.md) and [testing policy](../policy/testing.md):
|
|
||||||
|
|
||||||
- Imports: selection, scheduling, source/version evidence, placement, helper scopes,
|
|
||||||
ordinary/layered equivalence, filesystem failures, and resource lifetime.
|
|
||||||
- Inheritance: mapping and record relationships, nested destination resolution,
|
|
||||||
cancellation and replacement, policy ownership, history, sensitivity, and
|
|
||||||
independent loads.
|
|
||||||
- Projection: schema/decoding agreement, inline fields, text codecs, nil/empty
|
|
||||||
values, cycles and budgets, secrets, and detached results.
|
|
||||||
|
|
||||||
Add focused regressions only where a consequential handoff risk lacks coverage.
|
|
||||||
Tests should continue to describe behavior as private functions and files move.
|
|
||||||
Use the existing everyday check and applicable ownership race checks during
|
|
||||||
implementation. Complexity measurements can inform review; acceptance does not
|
|
||||||
depend on numerical scores or a new benchmark campaign.
|
|
||||||
|
|
||||||
Update the component overview and import internals to describe the implemented
|
|
||||||
ownership and file responsibilities when the refactor lands. Update contributor
|
|
||||||
navigation and test-owner references where moves affect them. Public behavior
|
|
||||||
continues to be defined by its current canonical owners.
|
|
||||||
|
|
||||||
## Completion Criteria
|
|
||||||
|
|
||||||
- Import callers pass cohesive named inputs, with clearly owned load-wide and
|
|
||||||
scope-local state and fewer redundant forwarding variants.
|
|
||||||
- Import acquisition, item assembly, and evidence projection are easy to locate
|
|
||||||
and follow across focused private files.
|
|
||||||
- Inheritance orchestration exposes value, policy, evidence, and accounting work
|
|
||||||
without requiring readers to reconstruct their interaction from one large body.
|
|
||||||
- Projection dispatch and substantial value-kind handlers have clear boundaries
|
|
||||||
and share the original operation state correctly.
|
|
||||||
- Existing APIs and contracted behavior, including completed remediation, remain
|
|
||||||
covered by the relevant behavioral and ownership checks.
|
|
||||||
- Internal documentation matches the resulting implementation.
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
# Composable Configuration Discovery Roadmap
|
|
||||||
|
|
||||||
Status: proposal with OD-1 through OD-5 and the final review clarifications
|
|
||||||
settled and integrated below.
|
|
||||||
This document describes future work, not an implemented contract. A staged
|
|
||||||
implementation plan will follow review of the complete roadmap.
|
|
||||||
|
|
||||||
## Purpose And Ownership
|
|
||||||
|
|
||||||
Make ordinary file-based loading concise for applications that want conventional
|
|
||||||
configuration locations. ConfigKit supplies useful built-in candidate lists and
|
|
||||||
an ordered selection operation. Consumers continue to own main-file search
|
|
||||||
conventions: they choose which lists apply, their order, custom candidates, and
|
|
||||||
whether a configuration file is required.
|
|
||||||
|
|
||||||
A consumer can select one preset for a simple application or compose several
|
|
||||||
presets and custom path lists. The application supplies an explicit slug where
|
|
||||||
needed to construct conventional paths. ConfigKit does not infer application
|
|
||||||
identity from the executable name or enable discovery for existing explicit
|
|
||||||
sources.
|
|
||||||
|
|
||||||
Discovery selects one source for the existing loading lifecycle. It does not
|
|
||||||
create additional configuration tiers or merge every matching file. Existing
|
|
||||||
layered loading remains available when consumers intentionally combine sources.
|
|
||||||
|
|
||||||
## Current Baseline
|
|
||||||
|
|
||||||
The [source contract](../config.md#sources) provides explicit host files, native
|
|
||||||
filesystem files, bytes, readers, mappings, and `NoSource()`. A missing explicit
|
|
||||||
file fails. The zero source is invalid. There is no conventional main-file search.
|
|
||||||
|
|
||||||
[Ordinary loading](../config.md#single-source-loading) infers a selected overlay's
|
|
||||||
filesystem context from the main source. `NoSource()` currently rejects enabled
|
|
||||||
imports, active overlays, and filesystem context. In
|
|
||||||
[layered loading](../config.md#layered-loading), an absent main input can coexist
|
|
||||||
with underlays; a selected overlay requires an explicit `OverlayFilesystem`.
|
|
||||||
These distinctions matter when an optional search finds nothing.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md#responsibility-boundaries)
|
|
||||||
assigns search conventions to consumers and currently excludes implicit main-file
|
|
||||||
discovery. The addition should clarify that consumers may explicitly select
|
|
||||||
library-provided search conventions. Consumer ownership remains unchanged.
|
|
||||||
|
|
||||||
## Agreed Direction
|
|
||||||
|
|
||||||
### Filename Groups, Directory Searches, And Search Presets
|
|
||||||
|
|
||||||
Organize discovery around three composable descriptions. These are ordered lists:
|
|
||||||
their order determines precedence.
|
|
||||||
|
|
||||||
**Filename groups** provide reusable, ordered filename templates. The built-in
|
|
||||||
groups are:
|
|
||||||
|
|
||||||
| Group | Filenames, in order |
|
|
||||||
| --- | --- |
|
|
||||||
| Named | `<appname>.yml`, `<appname>.yaml` |
|
|
||||||
| Generic | `config.yml`, `config.yaml`, `main.yml`, `main.yaml` |
|
|
||||||
|
|
||||||
`<appname>` is the application slug supplied by the consumer.
|
|
||||||
|
|
||||||
**Directory searches** pair a directory with an ordered list of filename groups.
|
|
||||||
The default selection is `[Named, Generic]`, producing the six filenames above
|
|
||||||
in that order. A directory search may select different groups or reverse their
|
|
||||||
precedence, such as `[Generic, Named]`, while retaining each group's internal
|
|
||||||
order. Consumers can also supply custom filenames, subject to the literal
|
|
||||||
single-component rules under OD-5 below.
|
|
||||||
|
|
||||||
**Search presets** bundle ordered directory searches. Consumers can select a
|
|
||||||
complete preset for the simple case or compose presets, custom directory searches,
|
|
||||||
and explicit file candidates in their desired order. Explicit file candidates
|
|
||||||
are literal paths and receive no filename expansion.
|
|
||||||
|
|
||||||
### Ordered Expansion And Concatenation
|
|
||||||
|
|
||||||
The selection model is **ordered expansion and concatenation**, followed by
|
|
||||||
first-match selection:
|
|
||||||
|
|
||||||
1. Expand the selected presets into directory searches in their supplied order.
|
|
||||||
2. For each directory search, concatenate its selected filename groups in order,
|
|
||||||
substitute the application slug where needed, and join each filename to that
|
|
||||||
directory.
|
|
||||||
3. Concatenate the resulting candidate lists, preserving the positions of custom
|
|
||||||
directory searches and explicit file candidates.
|
|
||||||
4. Try candidates in order, stopping at the first selected file or an error as
|
|
||||||
described below.
|
|
||||||
|
|
||||||
Directory priority comes before filename priority: exhaust one directory's
|
|
||||||
filenames before trying the next directory. When several directories share a
|
|
||||||
filename list, their expansion is a Cartesian product traversed directory first.
|
|
||||||
Different directories may select different lists, so ordered expansion and
|
|
||||||
concatenation describes the complete model. Set union does not express its
|
|
||||||
precedence semantics.
|
|
||||||
|
|
||||||
The eventual documentation should explain this model using the applicable
|
|
||||||
mathematical nomenclature: ordered lists, ordered concatenation, and Cartesian
|
|
||||||
product where a shared filename list applies. Retain these names in relevant
|
|
||||||
implementation comments where they help readers understand the operations. The
|
|
||||||
model describes ordering without requiring eager construction of every candidate.
|
|
||||||
|
|
||||||
### Unix System Preset
|
|
||||||
|
|
||||||
The agreed Unix system preset is:
|
|
||||||
|
|
||||||
| Directory, in search order | Filename groups, in order |
|
|
||||||
| --- | --- |
|
|
||||||
| `/usr/local/etc/<appname>` | `[Named, Generic]` |
|
|
||||||
| `/etc/<appname>` | `[Named, Generic]` |
|
|
||||||
| `/etc` | `[Named]` |
|
|
||||||
|
|
||||||
This produces 14 candidates: six in each application-specific directory, then
|
|
||||||
`/etc/<appname>.yml` and `/etc/<appname>.yaml`. For example,
|
|
||||||
`/usr/local/etc/<appname>/main.yaml` precedes
|
|
||||||
`/etc/<appname>/<appname>.yml`. Selecting only `Named` for `/etc` is an ordinary
|
|
||||||
directory-search description; the selection algorithm needs no special exception.
|
|
||||||
|
|
||||||
### Additional Presets (OD-1 — Settled)
|
|
||||||
|
|
||||||
Provide the following independently selectable presets alongside Unix system.
|
|
||||||
Within each preset, search directories in the order shown:
|
|
||||||
|
|
||||||
| Preset | Directory | Filename groups, in order |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| User configuration | `<user-config-dir>/<appname>` | `[Named, Generic]` |
|
|
||||||
| User configuration | `<user-config-dir>` | `[Named]` |
|
|
||||||
| Home directory | `<home>/.<appname>` | `[Named, Generic]` |
|
|
||||||
| Home directory | `<home>` | `[Named]` |
|
|
||||||
| Working directory | Current working directory | `[Named]` |
|
|
||||||
|
|
||||||
Resolve the user-configuration base with
|
|
||||||
[`os.UserConfigDir`](https://pkg.go.dev/os#UserConfigDir), the home base with
|
|
||||||
[`os.UserHomeDir`](https://pkg.go.dev/os#UserHomeDir), and the working-directory
|
|
||||||
base with [`os.Getwd`](https://pkg.go.dev/os#Getwd). Adopt the Go helpers'
|
|
||||||
platform-specific directory and environment behavior, including their errors;
|
|
||||||
the settled OD-2 rules below govern how discovery handles resolution errors.
|
|
||||||
|
|
||||||
The goal is to support familiar conventions through idiomatic Go. This feature
|
|
||||||
does not add a separate XDG compatibility layer or XDG system preset. A selected
|
|
||||||
user-configuration base has no additional hard-coded `<home>/.config` fallback.
|
|
||||||
The Home directory preset is a separate, explicit choice.
|
|
||||||
|
|
||||||
The working-directory preset searches only that directory and uses application-named
|
|
||||||
files; it does not walk ancestor directories. The consumer chooses precedence
|
|
||||||
between all presets. Selecting one does not automatically select any others.
|
|
||||||
|
|
||||||
### Illustrative Consumer API
|
|
||||||
|
|
||||||
Names below are illustrative and are not implemented Go API:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// A simple system-wide application.
|
|
||||||
source := configkit.SearchConfig("myapp", configkit.UnixSystemDirectories())
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// A consumer-selected working-directory, user, then system search.
|
|
||||||
source := configkit.SearchConfig(
|
|
||||||
"myapp",
|
|
||||||
configkit.WorkingDirectory(),
|
|
||||||
configkit.UserConfigDirectory(),
|
|
||||||
configkit.UnixSystemDirectories(),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Explicit custom candidates followed by a selected conventional fallback.
|
|
||||||
source := configkit.SearchConfig(
|
|
||||||
"myapp",
|
|
||||||
configkit.Paths("/run/myapp/config.yml", "/opt/myapp/config.yaml"),
|
|
||||||
configkit.UnixSystemDirectories(),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the resulting source with ordinary loading or as a layered main input. Custom
|
|
||||||
searches and file lists participate at their specified position; they do not
|
|
||||||
silently append unselected presets. Reordering presets or their directory
|
|
||||||
searches changes search priority.
|
|
||||||
|
|
||||||
### Selection And Failure Behavior
|
|
||||||
|
|
||||||
| Situation | Selected behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| Candidate is absent | Continue to the next candidate. |
|
|
||||||
| A candidate file is selected | Stop searching and load that file. |
|
|
||||||
| Candidate is inaccessible, unreadable, or not an accepted file type | Fail using normal source diagnostics. |
|
|
||||||
| Selected file fails parsing, version checks, composition, decoding, or validation | Fail without trying another candidate. |
|
|
||||||
| Entire search has no match | Absence is permitted by default; follow the shared absent-main lifecycle settled under OD-3 below. |
|
|
||||||
| Entire search has no match and is required | Fail with a diagnostic explaining the unsuccessful search. |
|
|
||||||
| Caller explicitly uses `File(path)` | Retain existing exclusive-file behavior, including errors for a missing file. |
|
|
||||||
|
|
||||||
Requiredness applies to the complete search, not to each preset or directory.
|
|
||||||
Permitting an absent file does not waive application version, schema, or domain
|
|
||||||
requirements; the remaining lifecycle may still reject an incomplete configuration.
|
|
||||||
|
|
||||||
Perform discovery before parsing the chosen main file. Do not implement fallback
|
|
||||||
by repeatedly running complete loads. Defaults, application resolution, secret
|
|
||||||
lookup, and final validation retain their existing lifecycle execution rules.
|
|
||||||
Once a file has been selected, later failure does not restart discovery.
|
|
||||||
|
|
||||||
### Construction And Filesystem Behavior
|
|
||||||
|
|
||||||
Constructors describe the search without filesystem access. Resolve dynamic base
|
|
||||||
directories and access candidate files during loading, so reusable descriptions
|
|
||||||
can observe changes on subsequent loads. Do not change the process working
|
|
||||||
directory or environment.
|
|
||||||
|
|
||||||
Reuse the host-file acquisition rules for selected files, including regular-file
|
|
||||||
checks, symlink handling, resource ownership, and source limits. The feature does
|
|
||||||
not require a new filesystem traversal engine, recursive directory search, or
|
|
||||||
new file-size limits. Candidate filenames and groups are bounded by their
|
|
||||||
explicit descriptions, rather than by scanning unrelated directories.
|
|
||||||
|
|
||||||
The exact Go names, constructors, and representation of search-wide options
|
|
||||||
belong in the implementation plan. Keep the public surface root-owned and keep
|
|
||||||
the single-preset case concise.
|
|
||||||
|
|
||||||
### Unavailable Bases And Platform Applicability (OD-2 — Settled)
|
|
||||||
|
|
||||||
Resolve directory bases only as the ordered search reaches them. Once an earlier
|
|
||||||
candidate is selected, later searches do not resolve their bases. Static invalid
|
|
||||||
search arguments still fail ordinary option validation before source acquisition.
|
|
||||||
An explicitly empty filename selection skips directory resolution as specified
|
|
||||||
under OD-4; it has no candidates to locate.
|
|
||||||
|
|
||||||
If a selected directory's base cannot be resolved, stop and report the error.
|
|
||||||
A missing directory or candidate file beneath a resolved base allows the search
|
|
||||||
to continue; a directory-resolution error does not. This distinction also applies
|
|
||||||
to optional searches: permitting no match does not suppress errors encountered
|
|
||||||
while searching. Preserve the agreed errors for inaccessible or unreadable
|
|
||||||
candidates.
|
|
||||||
|
|
||||||
Treat using the Unix system preset on an unsupported platform as an explicit
|
|
||||||
configuration error when that preset is reached. Specify its supported platforms
|
|
||||||
in the implementation plan. Do not translate the Unix preset into a different
|
|
||||||
platform's convention. User, home, and working-directory presets use their native
|
|
||||||
Go directory helpers.
|
|
||||||
|
|
||||||
This keeps search priority predictable when directory resolution fails. Consumers
|
|
||||||
select the presets appropriate to their application and environment; this proposal
|
|
||||||
does not add an option to skip unavailable bases or unsupported presets.
|
|
||||||
|
|
||||||
### No Match, Imports, And Selected Overlays (OD-3 — Settled)
|
|
||||||
|
|
||||||
Use a shared absent-main lifecycle for optional no-match and explicit `NoSource()`.
|
|
||||||
Allow imports to be enabled when the main file is absent: there are no main-file
|
|
||||||
import declarations to process, and enablement does not introduce automatic
|
|
||||||
directory scanning. Other actual sources retain their normal import behavior.
|
|
||||||
In layered loads, process underlays normally and retain the existing explicit
|
|
||||||
`OverlayFilesystem` requirement for an active overlay.
|
|
||||||
|
|
||||||
For ordinary loads with an active overlay and no main file, require an explicit
|
|
||||||
filesystem context to locate the overlay. Do not infer an overlay root from the
|
|
||||||
first or last searched directory. A missing selected overlay remains an error;
|
|
||||||
absence of a main file does not silently disable a requested overlay.
|
|
||||||
|
|
||||||
For an absent main input, a host filesystem context specifying only `Root` is
|
|
||||||
sufficient to locate the overlay; no main-file `BaseDir` is needed. If discovery
|
|
||||||
selects a host file, retain the normal file-context rules: the selected file
|
|
||||||
determines its base directory, an explicit `BaseDir` is rejected, and the file
|
|
||||||
must be contained within any supplied root. This allows one root-only context
|
|
||||||
to work with both selected and absent main inputs, subject to those existing
|
|
||||||
root and containment checks. An invalid context is an error, not a reason to
|
|
||||||
resume discovery.
|
|
||||||
|
|
||||||
Defaults, CLI overrides, application resolution, and validation continue through
|
|
||||||
the normal lifecycle. Main-version checking receives an absent main input and
|
|
||||||
may reject it under the application's existing policy. Create no synthetic empty
|
|
||||||
document or source record for the absent main file.
|
|
||||||
|
|
||||||
This deliberately relaxes the current `NoSource()` restrictions on imports and
|
|
||||||
on overlays with explicit context. Both ways of obtaining an absent main input
|
|
||||||
have the same behavior. Implementation must cover this lifecycle change as well
|
|
||||||
as candidate selection.
|
|
||||||
|
|
||||||
### Empty Composition And Implicit Preset Selection (OD-4 — Settled)
|
|
||||||
|
|
||||||
Require explicit preset or search selection to activate a convention. Supplying
|
|
||||||
no presets or custom searches produces no candidates and activates no implicit
|
|
||||||
fallback. Empty custom path lists and explicitly empty filename-group selections
|
|
||||||
also contribute no candidates. An empty component does not prevent later
|
|
||||||
components from contributing candidates.
|
|
||||||
|
|
||||||
A directory search with an explicitly empty filename selection does not resolve
|
|
||||||
its directory base. Static argument validation still applies. This gives empty
|
|
||||||
selections their no-candidate meaning without introducing directory-resolution
|
|
||||||
errors for a search that cannot select a file.
|
|
||||||
|
|
||||||
If the complete search is empty, an optional search follows the absent-main
|
|
||||||
lifecycle settled under OD-3; a required search fails as exhausted. Empty lists
|
|
||||||
are valid, supporting consumers that construct their searches programmatically.
|
|
||||||
|
|
||||||
Distinguish omission from an explicitly empty filename selection for a directory
|
|
||||||
search. Omitting the selection uses the agreed `[Named, Generic]` default;
|
|
||||||
explicitly selecting an empty list means no filenames. The eventual API must
|
|
||||||
represent both intentions clearly. Presets retain their specified filename groups.
|
|
||||||
|
|
||||||
Selecting a single preset keeps the simple case concise. Search-wide requiredness
|
|
||||||
provides the check for consumers that must find a configuration file.
|
|
||||||
|
|
||||||
### Slugs And Custom Path Interpretation (OD-5 — Settled)
|
|
||||||
|
|
||||||
Require a nonempty single-component application slug whenever a selected search
|
|
||||||
uses it to construct a directory or filename. Reject path separators, `.` and
|
|
||||||
`..`, and forms unusable as that component on the target platform. Preserve
|
|
||||||
spelling and case; do not trim, lowercase, or impose a kebab-case naming scheme.
|
|
||||||
Validation keeps the slug from changing the intended directory structure without
|
|
||||||
imposing a separate naming style.
|
|
||||||
|
|
||||||
Searches that do not use the slug do not require one. Examples include explicit
|
|
||||||
file candidates and a custom directory searched with only Generic filenames.
|
|
||||||
|
|
||||||
Treat custom paths as literal host paths. Resolve relative paths against the
|
|
||||||
working directory at load time. Do not expand shell variables, `~`, or wildcard
|
|
||||||
syntax. Consumers can construct expanded paths themselves; User and Home presets
|
|
||||||
provide the common directory-resolution conveniences. These rules keep custom
|
|
||||||
paths consistent with explicit file loading.
|
|
||||||
|
|
||||||
Custom filenames within directory searches are literal single filename
|
|
||||||
components. They cannot be absolute paths or contain subdirectory components;
|
|
||||||
use custom directory searches or explicit file candidates for those layouts.
|
|
||||||
They receive no application-name placeholder expansion. The Named group supplies
|
|
||||||
application-name expansion, while consumers may construct custom filenames
|
|
||||||
themselves before supplying them.
|
|
||||||
|
|
||||||
### Placement, Provenance, And Existing Features
|
|
||||||
|
|
||||||
The selected file participates as the actual file source. Preserve its physical
|
|
||||||
identity, effective placement, source origin, and passive resource context.
|
|
||||||
Relative imports and relative application resources use the selected file's
|
|
||||||
normal context. Discovery does not relocate overlay or CLI coordinates.
|
|
||||||
|
|
||||||
Missing candidates are not acquired sources and do not create source IDs,
|
|
||||||
configuration history, or explicit input presence. An entirely absent search
|
|
||||||
contributes no synthetic empty configuration file. Diagnostics should distinguish
|
|
||||||
an exhausted required search from a failed candidate. Existing selected-source
|
|
||||||
inspection remains the foundation; a new public per-attempt trace is not needed
|
|
||||||
for this proposal.
|
|
||||||
|
|
||||||
Preserve collection policies, inheritance, source-version rules, CLI ordering,
|
|
||||||
final validation, and frozen inspection after selection. Search-list order does
|
|
||||||
not change precedence among underlays, the main input, an overlay, and CLI values.
|
|
||||||
An explicitly selected overlay is not another main-file fallback candidate.
|
|
||||||
|
|
||||||
## Work Needed From The Baseline
|
|
||||||
|
|
||||||
The subsequent implementation plan should cover:
|
|
||||||
|
|
||||||
- Define filename-group, directory-search, preset, and search-source representations,
|
|
||||||
slug validation, and a search-wide requiredness option. Keep construction
|
|
||||||
separate from acquisition and the simple preset selection concise. Distinguish
|
|
||||||
omitted and explicitly empty directory-search filename selections.
|
|
||||||
- Implement ordered expansion and concatenation for presets, filename groups,
|
|
||||||
custom searches, and explicit file candidates, followed by first-match selection
|
|
||||||
through existing file access. Resolve each source once per consumption before
|
|
||||||
entering its parse and composition lifecycle.
|
|
||||||
- Integrate selected and absent outcomes with ordinary and layered main inputs,
|
|
||||||
placement, imports, application version checking, and overlay contexts according
|
|
||||||
to OD-3, including root-only host contexts for absent main inputs and normal
|
|
||||||
file-context checks when a file is selected. Preserve ownership and close
|
|
||||||
acquired handles on success and failure.
|
|
||||||
- Preserve real selected-source metadata and safe diagnostics without treating
|
|
||||||
absent candidates as configuration contributions.
|
|
||||||
- Exercise preset, directory, filename-group, and filename ordering, reversed
|
|
||||||
group precedence, the Unix system preset's 14 candidates and named-only `/etc`
|
|
||||||
search, custom-list composition, both filename extensions, no-match behavior,
|
|
||||||
empty components and searches, omitted versus empty filename selections,
|
|
||||||
skipped directory resolution for empty filename selections, slug-dependent and
|
|
||||||
slug-independent searches, literal custom paths and filename components,
|
|
||||||
required searches, unavailable preset bases, errors that must stop fallback,
|
|
||||||
repeated loads, and untouched explicit `File`
|
|
||||||
behavior. Cover imports, overlays, underlays, defaults, CLI, source versions,
|
|
||||||
source contexts, and source-list/inspection evidence at their boundaries.
|
|
||||||
- Clarify the architecture policy's consumer-selected discovery boundary. Record
|
|
||||||
any warranted durable ownership decision in an ADR; keep preset filenames and
|
|
||||||
API mechanics in their canonical contract when implemented. Update consumer
|
|
||||||
examples and the configuration contract when the feature lands. Explain the
|
|
||||||
three-part discovery model and its mathematical terminology in the eventual
|
|
||||||
documentation, with simple preset selection and custom composition examples.
|
|
||||||
|
|
||||||
This roadmap does not change implementation, policy, or ADRs by itself. Coordinate
|
|
||||||
implementation with the ongoing sequence integration audit rather than assuming
|
|
||||||
its in-progress changes are part of a fixed implementation baseline.
|
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
# 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. Recursive body merging for identified records
|
## 1. Separate glob entry preparation from traversal
|
||||||
|
|
||||||
|
Implemented in the [internal-boundary roadmap](internal-boundaries.md#glob-entry-preparation) (C03).
|
||||||
|
|
||||||
|
## 2. Simplify redundant facade-to-engine transport
|
||||||
|
|
||||||
|
Implemented in the [internal-boundary roadmap](internal-boundaries.md#facade-to-engine-transport) (F07).
|
||||||
|
|
||||||
|
## 3. Recursive body merging for identified records
|
||||||
|
|
||||||
Status: deferred. The implemented
|
Status: deferred. The implemented
|
||||||
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
||||||
@@ -20,7 +28,7 @@ presence and ordering, and how provenance, history, sensitivity, and resource
|
|||||||
context follow partially retained records. Any change requires a new decision and
|
context follow partially retained records. Any change requires a new decision and
|
||||||
implementation plan; this entry does not alter current upsert behavior.
|
implementation plan; this entry does not alter current upsert behavior.
|
||||||
|
|
||||||
## 2. Required version declarations for selected sources
|
## 4. 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.
|
||||||
@@ -56,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:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
346
docs/roadmap/interface-remediation.md
Normal file
346
docs/roadmap/interface-remediation.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# Interface Contract Remediation Roadmap
|
||||||
|
|
||||||
|
Status: target settled, including shared history ownership; implementation has
|
||||||
|
not begun.
|
||||||
|
Planning baseline: `1f64673` (v0.10.2 implementation plus documentation changes).
|
||||||
|
|
||||||
|
This roadmap covers the four findings in the [interface audit](interfaces.md).
|
||||||
|
The audit owns reproductions, evidence, and finding numbers. This document owns
|
||||||
|
the combined target, required changes, and general dependency order. A subsequent
|
||||||
|
implementation plan should own concrete stages, private implementation choices,
|
||||||
|
validation commands, and progress.
|
||||||
|
|
||||||
|
## Purpose And Scope
|
||||||
|
|
||||||
|
Restore agreement between ConfigKit's presence, metadata, history, and diagnostic
|
||||||
|
interfaces under their existing contracts. The first three findings concern
|
||||||
|
semantic paths that share a JSON Pointer spelling; the fourth concerns a
|
||||||
|
composition claimant order that is lost during diagnostic canonicalization.
|
||||||
|
|
||||||
|
Build one shared internal history model from which metadata history and
|
||||||
|
explanation are derived. It will own the historical evidence and semantic
|
||||||
|
attribution used by both views, replacing their independent reconstruction of
|
||||||
|
that attribution. This is the selected resolution of OD-1.
|
||||||
|
|
||||||
|
The [Path declarations](../../path.go), [metadata contract](../config.md#layered-metadata),
|
||||||
|
[resolution contract](../config.md#recorded-application-resolution), and
|
||||||
|
[strict-composition contract](../config.md#strict-document-composition) remain
|
||||||
|
authoritative. This work changes the implementation of those contracts without
|
||||||
|
selecting a new public API, path syntax, precedence rule, or configuration
|
||||||
|
restriction. The existing root-owned facade and engine responsibilities remain.
|
||||||
|
|
||||||
|
## Current Baseline And Targeted End State
|
||||||
|
|
||||||
|
An explicit mapping-key path and an explicit sequence-index path can both render
|
||||||
|
as `/shape/0`. `ParsePath` deliberately leaves numeric tokens contextual, whereas
|
||||||
|
`Keys` and `Index` preserve the caller's explicit choice. The engine already has
|
||||||
|
shape-aware effective/historical path indexes and a frozen resolution-entry view,
|
||||||
|
but some queries and metadata updates still use pointer spelling alone.
|
||||||
|
|
||||||
|
History currently reconstructs definition and previous-origin records, while
|
||||||
|
explanation replays source contributions, inheritance, decoding checkpoints, CLI
|
||||||
|
assignments, and grouped resolution snapshots. Neither public representation
|
||||||
|
contains everything needed by the other. The target is shared underlying evidence
|
||||||
|
with distinct projections for each interface, preserving their existing behavior.
|
||||||
|
|
||||||
|
| Finding | Current discrepancy | Target |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1: resolution presence | `IsExplicit` and `Default` consult string-keyed presence after schema validation, which cannot determine actual shape beneath dynamic values. | Presence decisions use the matching semantic path in the fixed resolution-entry state. |
|
||||||
|
| 2: effective explicitness | Resolution retains old field information when pointer spelling survives a container-shape change. | Input explicitness carries forward for the same semantic path; a newly introduced key or index does not inherit another path's explicitness. |
|
||||||
|
| 3: history attribution | A historical-shape check admits the query, then `History` returns the entire textual-pointer bucket; explanation selects evidence separately. | Both interfaces derive attribution from one shared history model, selecting exact definitions, previous origins, and applicable ancestor operations by semantic path. |
|
||||||
|
| 4: claimant order | Composition constructs related locations in assembly order, then canonicalization sorts them by location. | Structured and rendered composition conflicts preserve the selected claimant order. |
|
||||||
|
|
||||||
|
For example, if input contains `shape: {'0': old}` and recorded application
|
||||||
|
resolution changes `shape` to a sequence, the old key remains an input path in
|
||||||
|
the callback's entry view. The new index is effective in final metadata but was
|
||||||
|
not explicitly supplied by that input. Its history must not present the old key's
|
||||||
|
definition as its own. This distinction concerns container shape, not identity
|
||||||
|
tracking for records that move within a sequence.
|
||||||
|
|
||||||
|
## Required Changes
|
||||||
|
|
||||||
|
### 1. Use Entry-State Shape For Resolution Presence
|
||||||
|
|
||||||
|
In [resolution.go](../../internal/engine/resolution.go), align `IsExplicit` and
|
||||||
|
the explicitness decision in `Default` with the frozen, shape-aware entry metadata
|
||||||
|
already used by `InputInfo` in
|
||||||
|
[load_metadata.go](../../internal/engine/load_metadata.go).
|
||||||
|
|
||||||
|
Reuse the existing semantic-path matching machinery. A shared private lookup can
|
||||||
|
avoid divergent presence decisions without requiring every public query to have
|
||||||
|
identical error behavior. In particular:
|
||||||
|
|
||||||
|
- Preserve `IsExplicit`'s schema-path validation and its existing diagnostics.
|
||||||
|
`InputInfo` remains a query that returns false for missing or mismatched paths
|
||||||
|
without those diagnostics.
|
||||||
|
- Preserve `Default`'s helper-target validation, assignment, recording, and final
|
||||||
|
helper-value checks. Correct its presence gate; do not bypass the other checks.
|
||||||
|
- Use entry-state shape even after application code changes the working value.
|
||||||
|
Explicit false, zero, empty, and null retain their existing presence meaning.
|
||||||
|
- Keep callback lifetime and detached metadata behavior intact.
|
||||||
|
|
||||||
|
A shape mismatch beneath a schema-valid dynamic path must not become explicit
|
||||||
|
merely because its pointer spelling matches an input path. Contextually parsed
|
||||||
|
numeric tokens continue to resolve against the entry state's actual containers.
|
||||||
|
|
||||||
|
### 2. Preserve Explicitness By Semantic Path During Resolution
|
||||||
|
|
||||||
|
Update resolution metadata application and field refresh in
|
||||||
|
[load_metadata.go](../../internal/engine/load_metadata.go) to compare the relevant
|
||||||
|
entry and final shapes before reusing presence information.
|
||||||
|
|
||||||
|
The comparison must account for container kinds along the full path. A mapping
|
||||||
|
key becoming a sequence index at an ancestor can change a descendant's semantic
|
||||||
|
path even when its last segment and full pointer spelling stay unchanged.
|
||||||
|
|
||||||
|
Keep explicitness for matching surviving paths, including descendants covered by
|
||||||
|
an ancestor normalization record. New semantic paths introduced by resolution
|
||||||
|
remain non-explicit, and retain the applicable recorded resolution origin.
|
||||||
|
Removed paths leave effective metadata while remaining available to historical
|
||||||
|
queries under the existing contract. Do not clear every descendant's presence
|
||||||
|
just because its parent was normalized.
|
||||||
|
|
||||||
|
Use the existing before/after projection and entry metadata where possible.
|
||||||
|
This work does not require tracking every transient mutation inside a callback;
|
||||||
|
the existing net-change and coalesced-record model continues to apply.
|
||||||
|
|
||||||
|
### 3. Build A Shared Internal History Model
|
||||||
|
|
||||||
|
#### Evidence And Ownership
|
||||||
|
|
||||||
|
Give historical evidence one private owner in the engine. Composition,
|
||||||
|
inheritance, decoding, CLI assignment, and resolution continue to perform their
|
||||||
|
existing work and supply evidence to that owner. The model does not drive a
|
||||||
|
second configuration load or repeat application callbacks.
|
||||||
|
|
||||||
|
Organize the evidence into ordered groups with the phase boundaries required by
|
||||||
|
the existing inspection contracts:
|
||||||
|
|
||||||
|
| Evidence group | Information to preserve |
|
||||||
|
| --- | --- |
|
||||||
|
| Default baseline | Canonical typed application defaults or Go-zero values and their default attribution. |
|
||||||
|
| Source contributions | Underlay, main-tier, and selected-overlay contributions in their existing order; exact definitions, composite origins, displaced definitions, raw policy-validated values, and sequence-position projections. |
|
||||||
|
| Inheritance | Parent transfers, child contributions including equal-valued replacements, controlling directives and their consumption, identity-field preservation, and contribution origins/dependencies. |
|
||||||
|
| Typed checkpoints | Pre-CLI and post-CLI typed projections and attribution needed to reconcile retained defaults and raw contributions with decoded values. |
|
||||||
|
| CLI contributions | Explicit assignments, including equal-valued assignments, raw supplied representation, source attribution, and affected paths. |
|
||||||
|
| Resolution | Original recorded operations, reasons, dependencies, and before/after evidence, with the grouping needed to explain overlapping records as net subtree changes. |
|
||||||
|
|
||||||
|
The logical order remains baseline, source contributions, inheritance, pre-CLI
|
||||||
|
decoding reconciliation, CLI contributions, post-CLI typed reconciliation, then
|
||||||
|
recorded resolution. The two typed checkpoints belong on their respective sides
|
||||||
|
of the CLI contribution phase.
|
||||||
|
|
||||||
|
The groups describe logical inspection chronology, not a transcript of every
|
||||||
|
physical mutation. Retain the existing net-change and coalescing rules for
|
||||||
|
resolution. Preserve superseded raw input that need not decode into the final
|
||||||
|
application type; typed checkpoints do not replace that evidence.
|
||||||
|
|
||||||
|
Use a small set of ordinary private structures and existing document/path
|
||||||
|
mechanisms. Exact types, indexes, and storage layout belong in the implementation
|
||||||
|
plan. Complete evidence means enough for the existing history, explanation, and
|
||||||
|
internal attribution consumers; it does not add persistence, a public event API,
|
||||||
|
or recording of transient application effects.
|
||||||
|
|
||||||
|
#### Shared Selection And Attribution
|
||||||
|
|
||||||
|
Retain concrete semantic-path evidence when it is available. A query over the
|
||||||
|
model must distinguish exact definitions from operations affecting an ancestor
|
||||||
|
or descendant, determine the relevant previous origins, and apply sequence
|
||||||
|
contribution coordinates at their assigned positions.
|
||||||
|
|
||||||
|
The affected semantic path and the operation's reported path are distinct facts.
|
||||||
|
For example, a replacement at `/shape` can remove a mapping key and introduce a
|
||||||
|
sequence index with the same pointer spelling. Preserve enough before/after shape
|
||||||
|
evidence to answer each explicit query correctly. Merely filtering `Change.Path`
|
||||||
|
after concrete segment kinds have been discarded is insufficient.
|
||||||
|
|
||||||
|
Make this selection and attribution common to both projections, including
|
||||||
|
inheritance integration and `Previous` reconstruction. A false definition must
|
||||||
|
not survive as a previous origin after it has been excluded from the query's
|
||||||
|
history. Preserve genuine ancestor replacement/removal effects and chronology.
|
||||||
|
A path that never existed does not acquire history solely because an ancestor
|
||||||
|
changed.
|
||||||
|
|
||||||
|
Keep the contextual meaning of parsed numeric tokens across historical states
|
||||||
|
and the one-entry-per-canonical-pointer enumeration of `HistoricalPaths`.
|
||||||
|
Indexes and compact derived records may remain, but their attribution must come
|
||||||
|
from the shared evidence. Metadata queries should not require full configuration
|
||||||
|
replay or YAML rendering to obtain definition and origin records.
|
||||||
|
|
||||||
|
#### Distinct Public Projections
|
||||||
|
|
||||||
|
`Metadata.History` produces detached definition, inheritance, and resolution
|
||||||
|
records from the model. `Explain` reconstructs and safely renders values from
|
||||||
|
the same evidence, retaining its existing decoding and grouping behavior. Neither
|
||||||
|
public API should be implemented by translating the other's public result.
|
||||||
|
|
||||||
|
Preserve these intentional differences:
|
||||||
|
|
||||||
|
| Case | Metadata history | Explanation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Several strict contributions to a mapping | Composite definition/origin information. | Each contribution's effect on the mapping. |
|
||||||
|
| Raw input differs from canonical typed values | Definition and attribution records. | The applicable decoding reconciliation transition. |
|
||||||
|
| Overlapping resolution records | Specific recorded attribution under the existing coverage rules. | One net subtree event with combined reasons and dependencies. |
|
||||||
|
| Equal-valued input or inheritance operations | Explicit definition and operation attribution remains visible. | Relevant operations remain visible even when before and after values are equal. |
|
||||||
|
|
||||||
|
Agreement means consistent semantic identity and attribution, not identical
|
||||||
|
record lists. Retain original resolution records as evidence from which net
|
||||||
|
replay groups are derived; a grouped explanation alone loses information needed
|
||||||
|
by metadata.
|
||||||
|
|
||||||
|
#### Replace Existing Attribution Machinery
|
||||||
|
|
||||||
|
The common model must replace independent historical attribution, rather than
|
||||||
|
become another representation alongside it. The implementation plan should map
|
||||||
|
the following owners to their replacements:
|
||||||
|
|
||||||
|
| Current owner | Required change |
|
||||||
|
| --- | --- |
|
||||||
|
| History-building portions of `metadataFromDecoded`, `applyOverlayMetadata`, and `appendPostDecodeCLIHistory` in [load_metadata.go](../../internal/engine/load_metadata.go) and [override_context.go](../../internal/engine/override_context.go) | Produce or project shared evidence instead of independently reconstructing pointer-keyed histories. Retain effective-field construction where it is still needed. |
|
||||||
|
| Previous-origin reconstruction and inheritance insertion in [inheritance_history.go](../../internal/engine/inheritance_history.go) | Use common semantic selection and attribution while preserving the existing inheritance projection rules. |
|
||||||
|
| Relevance and chronology orchestration in [explain.go](../../internal/engine/explain.go) | Consume the shared model; retain useful value reconstruction and redaction helpers. |
|
||||||
|
| Separate published inheritance evidence for metadata and explanation in [load_result.go](../../internal/engine/load_result.go) | Consolidate under the frozen history owner where independent copies are unnecessary; keep required output detachment. |
|
||||||
|
| Original and coalesced resolution evidence in [history.go](../../internal/engine/history.go) | Own original records in the model and derive the replay grouping without losing specific attribution or maintaining a second independently interpreted history. |
|
||||||
|
|
||||||
|
Existing source, sequence-combination, and inheritance evidence producers may
|
||||||
|
remain useful. Their algorithms need not be rewritten merely to contribute to
|
||||||
|
the model. Likewise, final effective fields, the frozen resolution-entry view,
|
||||||
|
source records, and final configuration snapshots retain their responsibilities.
|
||||||
|
Findings 1 and 2 still require explicit presence corrections.
|
||||||
|
|
||||||
|
#### Other Consumers, Lifetime, And Resource Accounting
|
||||||
|
|
||||||
|
Move historical dependency reads in
|
||||||
|
[secret classification](../../internal/engine/secret.go) and historical location
|
||||||
|
fallbacks in [override_context.go](../../internal/engine/override_context.go) to
|
||||||
|
the shared evidence. Do not retain the old history machinery solely for those
|
||||||
|
readers. Preserve existing sensitivity propagation and historical classification;
|
||||||
|
correcting attribution does not authorize narrowing redaction for paths that
|
||||||
|
share a pointer spelling.
|
||||||
|
|
||||||
|
Evidence must be available at the lifecycle stages where these internal readers
|
||||||
|
need it, including diagnostic attribution before publication. Keep the callback's
|
||||||
|
entry metadata fixed while later resolution evidence is added. Finalize the
|
||||||
|
model for published queries without introducing lazy mutation of shared state.
|
||||||
|
Private frozen evidence may be shared by the two views while their returned
|
||||||
|
records remain detached under the existing ownership contract.
|
||||||
|
|
||||||
|
Reuse retained trees and existing lookup machinery where practical. Review
|
||||||
|
retained-history accounting against the resulting ownership graph, including
|
||||||
|
typed checkpoints and inheritance/resolution evidence. The model does not require
|
||||||
|
a full document copy per event, a new quota, or a new concurrency guarantee.
|
||||||
|
Reduced duplication is a design benefit to evaluate, not a promised memory or
|
||||||
|
speed improvement.
|
||||||
|
|
||||||
|
### 4. Preserve Composition Claimant Order Through Diagnostics
|
||||||
|
|
||||||
|
Keep the order produced by `compositionConflictDiagnostic` in
|
||||||
|
[compose.go](../../internal/engine/compose.go) through accumulation, `LoadError`,
|
||||||
|
facade conversion, and `WriteDiagnostics` in
|
||||||
|
[diagnostic.go](../../internal/engine/diagnostic.go).
|
||||||
|
|
||||||
|
Prefer a narrow internal canonicalization rule based on the diagnostic semantics
|
||||||
|
already carried across these boundaries. Identify the composition diagnostic
|
||||||
|
producers governed by the claimant-order contract rather than changing all
|
||||||
|
related-location lists. Preserve canonical ordering of diagnostic records and
|
||||||
|
the existing sorting of related locations for other diagnostics.
|
||||||
|
|
||||||
|
The root renderer converts public diagnostic records back to engine records and
|
||||||
|
canonicalizes them again. An engine-only marker discarded by facade conversion
|
||||||
|
would therefore be insufficient. No public ordering option is needed for this
|
||||||
|
correction. Keep primary selection, conflict detection, acquisition order, and
|
||||||
|
dependency-ready scheduling unchanged.
|
||||||
|
|
||||||
|
## General Implementation Order
|
||||||
|
|
||||||
|
1. Prepare the concrete shared-evidence design and focused regression cases.
|
||||||
|
Map existing producers and consumers to the new owner, distinguish common
|
||||||
|
attribution from presentation rules, and identify the old paths to remove.
|
||||||
|
Select representations and indexes that preserve existing query and lifetime
|
||||||
|
needs without adding public contracts.
|
||||||
|
2. Repair entry-state presence queries and `Default` (finding 1). This is a local
|
||||||
|
correction that can proceed independently of the larger history work.
|
||||||
|
3. Repair final explicitness preservation (finding 2), retaining the shape evidence
|
||||||
|
needed by metadata updates. Coordinate this work with history construction so
|
||||||
|
the two fixes do not introduce competing definitions of path identity.
|
||||||
|
4. Build the shared model and migrate metadata history, explanation, sensitivity
|
||||||
|
dependencies, and diagnostic-location readers. Preserve phase evidence and
|
||||||
|
derive both public projections through common semantic attribution. Remove
|
||||||
|
the superseded reconstruction paths and reconcile retained-history accounting.
|
||||||
|
Verify finding 3 and its interaction with final presence metadata; repairing
|
||||||
|
pointer buckets alone does not complete the selected architecture.
|
||||||
|
5. Complete composition diagnostic ordering (finding 4) as an independent change;
|
||||||
|
it may proceed alongside the path work. Check rendering as well as the
|
||||||
|
structured error before considering it complete.
|
||||||
|
6. Review the integrated result, run applicable development checks, and update
|
||||||
|
affected implementation documentation and finding status.
|
||||||
|
|
||||||
|
These are high-level dependencies, not a requirement to combine unrelated fixes
|
||||||
|
in one commit. The implementation plan should select concrete edits and test
|
||||||
|
commands while keeping the four findings individually traceable.
|
||||||
|
|
||||||
|
## Validation And Completion
|
||||||
|
|
||||||
|
Follow the [testing policy](../policy/testing.md) and use the
|
||||||
|
[development guide](../development.md#baseline-validation) for checks. Extend
|
||||||
|
existing test owners rather than recreating full internal matrices at the facade.
|
||||||
|
The focused cases should cover:
|
||||||
|
|
||||||
|
- Both mapping-key/index mismatch directions beneath dynamic values, including
|
||||||
|
explicit paths and contextual parsed pointers. Check callback presence and
|
||||||
|
`Default` behavior, including the fixed entry view after working-value changes.
|
||||||
|
- Both container-shape transitions during recorded resolution, a descendant
|
||||||
|
affected by an ancestor shape change, and a same-shape normalization control.
|
||||||
|
Check explicitness and recorded origins through final metadata and explanation.
|
||||||
|
- Shape-changing CLI and resolution history, checking exact definitions,
|
||||||
|
`Previous`, and genuine ancestor replacement/removal. Include contextual
|
||||||
|
enumeration/query coverage and preserve existing inheritance and overlay cases;
|
||||||
|
add another case only where those integrations expose an unprotected risk.
|
||||||
|
- Shared-model projections across composite mapping origins, typed decoding and
|
||||||
|
default reconciliation, equal-valued writes, inheritance controls/transfers,
|
||||||
|
mapped sequence replacements and removed descendants, and overlapping
|
||||||
|
resolution records. Reuse existing cases where they protect those semantics;
|
||||||
|
check the intentional differences between the public projections as well as
|
||||||
|
agreement on attribution.
|
||||||
|
- Historical sensitivity/dependency propagation, diagnostic source-location
|
||||||
|
fallback, detached returned records, and complete retained-history accounting
|
||||||
|
under the new ownership graph. Use small controlled budget cases and existing
|
||||||
|
disclosure fixtures rather than memory or timing thresholds.
|
||||||
|
- A composition conflict whose acquisition and assembly orders differ, using the
|
||||||
|
audit fixture. Check both published diagnostics and rendered related-location
|
||||||
|
order, while retaining coverage for ordinary diagnostic canonicalization.
|
||||||
|
|
||||||
|
Retain existing checks for invalid schema paths, callback lifetime, detached
|
||||||
|
records, safe historical rendering, and default/null presence semantics. Test
|
||||||
|
observable behavior rather than a particular private index or number of copies.
|
||||||
|
No benchmark campaign, broader audit, or new release gate is part of this roadmap.
|
||||||
|
|
||||||
|
Completion requires all four findings to have implementation and regression
|
||||||
|
evidence, appropriate development checks, and both history interfaces deriving
|
||||||
|
semantic attribution from the shared model. Review the final producer/consumer
|
||||||
|
map to confirm superseded independent reconstruction has been removed and
|
||||||
|
internal attribution readers have migrated. Passing the four defect regressions
|
||||||
|
while retaining the old parallel attribution machinery is not completion.
|
||||||
|
|
||||||
|
The audit's previous passing suite is not validation of these future changes.
|
||||||
|
Scope here does not promise identical output from different inspection interfaces
|
||||||
|
or a universal guarantee about unrelated path-processing code.
|
||||||
|
|
||||||
|
## Decision Status
|
||||||
|
|
||||||
|
OD-1 is settled: build one shared internal history model with distinct metadata
|
||||||
|
and explanation projections. The design investigation found that both views
|
||||||
|
require evidence the other's public representation does not preserve. Shared
|
||||||
|
ownership can replace independent attribution and redundant retained evidence,
|
||||||
|
while keeping the current lifecycle and public contracts.
|
||||||
|
|
||||||
|
The narrower repair of stored history events would leave the separate replay
|
||||||
|
attribution owner in place. Promoting current explanation output to authoritative
|
||||||
|
history would lose composite-definition and specific resolution information.
|
||||||
|
The selected common underlying model addresses both concerns. This accepts a
|
||||||
|
larger internal refactor than a local shape-filtering fix.
|
||||||
|
|
||||||
|
No significant open decision remains at roadmap level. Private representation,
|
||||||
|
indexing, and helper choices belong in the implementation plan. This roadmap
|
||||||
|
records future implementation ownership; it does not revise accepted ADRs or
|
||||||
|
declare the model implemented.
|
||||||
202
docs/roadmap/interfaces.md
Normal file
202
docs/roadmap/interfaces.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# API and Interface Contract Audit
|
||||||
|
|
||||||
|
Reviewed baseline: `01b08e76a3fecc22c4414437bfeaa07b4b7a85aa`.
|
||||||
|
|
||||||
|
Status: four open findings. This is an audit record, not an implementation plan.
|
||||||
|
No production code or repository tests were changed.
|
||||||
|
|
||||||
|
The [interface remediation roadmap](interface-remediation.md) describes the
|
||||||
|
settled target, required changes, and dependencies, including a shared internal
|
||||||
|
history model for metadata history and explanation. Implementation remains pending.
|
||||||
|
This audit retains the findings and reproduction evidence.
|
||||||
|
|
||||||
|
## Scope and evidence
|
||||||
|
|
||||||
|
Reviewed the root-owned public facade and its conversions, engine loading and
|
||||||
|
callback boundaries, source/discovery and filesystem ownership, schema and typed
|
||||||
|
value handling, metadata/history, safe rendering and comparison, secrets, and
|
||||||
|
the document/YAML boundary against declaration comments, the
|
||||||
|
[configuration contract](../config.md), and applicable architectural decisions.
|
||||||
|
The root delegates to the synchronous engine lifecycle; parser-independent
|
||||||
|
documents support source assembly and inheritance, and completed results retain
|
||||||
|
detached configuration and frozen inspection state.
|
||||||
|
|
||||||
|
The findings below were reproduced through the public API with a disposable Go
|
||||||
|
program outside the repository, using labeled byte sources and `fstest.MapFS`.
|
||||||
|
The full existing suite passed with Go 1.26.10 on Linux:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOTOOLCHAIN=local GOTELEMETRY=off GOPROXY=off go test ./... -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing tests cover many ownership, lifetime, error, and zero-value cases,
|
||||||
|
but do not detect these cross-interface discrepancies. No benchmarks, external
|
||||||
|
service calls, or new test files were needed. The recommendations restore
|
||||||
|
existing contracts rather than introduce new API restrictions or guarantees.
|
||||||
|
|
||||||
|
## 1. Resolution presence queries confuse explicit keys with sequence indexes
|
||||||
|
|
||||||
|
Priority: P2. Status: open.
|
||||||
|
|
||||||
|
Relevant code:
|
||||||
|
|
||||||
|
- [`Resolution.IsExplicit`](../../internal/engine/resolution.go), lines 75–82.
|
||||||
|
- `Resolution.InputInfo` and `resolutionInput.lookup` in
|
||||||
|
[resolution.go](../../internal/engine/resolution.go) and
|
||||||
|
[load_metadata.go](../../internal/engine/load_metadata.go).
|
||||||
|
- `resolveSchemaPath` in [schema.go](../../internal/engine/schema.go).
|
||||||
|
|
||||||
|
The public [path contract](../../path.go) distinguishes explicit mapping keys
|
||||||
|
from sequence indexes. The [resolution contract](../config.md#recorded-application-resolution)
|
||||||
|
defines surviving-input presence and a fixed entry-state metadata view;
|
||||||
|
[metadata queries](../config.md#layered-metadata) preserve container shapes.
|
||||||
|
|
||||||
|
`IsExplicit` validates against the schema but then looks up presence using only
|
||||||
|
`path.String()`. In dynamic `any` state, schema validation cannot establish the
|
||||||
|
actual container shape. Consequently these distinct queries collide:
|
||||||
|
|
||||||
|
```go
|
||||||
|
key := configkit.Keys("shape", "0")
|
||||||
|
index := configkit.Keys("shape").Index(0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Load `map[string]any` from `shape: [input]` with a resolver that queries both
|
||||||
|
paths, without changing the configuration. Observed results:
|
||||||
|
|
||||||
|
| Query | `Resolution.IsExplicit` | `Resolution.InputInfo` found | Final `Metadata.IsExplicit` |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Explicit mapping key | true | false | false |
|
||||||
|
| Explicit sequence index | true | true | true |
|
||||||
|
|
||||||
|
The mapping-key path never existed and should not be reported as explicit.
|
||||||
|
Applications branching on presence receive inconsistent answers depending on
|
||||||
|
which public query they use, even during an otherwise unchanged load.
|
||||||
|
|
||||||
|
Potential remediation: use the retained entry-state semantic shape when checking
|
||||||
|
resolution presence, consistently with `InputInfo` and `Metadata.IsExplicit`.
|
||||||
|
Review the same string-only presence lookup in `Default` while preserving its
|
||||||
|
existing assignment and callback-lifetime rules. Add focused public coverage for
|
||||||
|
both mapping-to-index and index-to-mapping mismatches under dynamic values,
|
||||||
|
including contextually parsed pointers. This does not require changing the
|
||||||
|
documented treatment of invalid schema paths.
|
||||||
|
|
||||||
|
## 2. Resolution can transfer explicitness to a different semantic path
|
||||||
|
|
||||||
|
Priority: P2. Status: open.
|
||||||
|
|
||||||
|
Relevant code: `applyResolutionMetadata`, `applyResolutionFieldOrigins`, and
|
||||||
|
`refreshResolutionFields` in
|
||||||
|
[load_metadata.go](../../internal/engine/load_metadata.go), especially lines
|
||||||
|
371–387; final shape checking in
|
||||||
|
[`Metadata.effectiveField`](../../internal/engine/metadata.go).
|
||||||
|
|
||||||
|
The [metadata contract](../config.md#layered-metadata) distinguishes key/index
|
||||||
|
shapes and keeps derived values for absent input non-explicit. Recorded
|
||||||
|
resolution changes origins without creating input explicitness.
|
||||||
|
|
||||||
|
Reproduction:
|
||||||
|
|
||||||
|
1. Load `map[string]any` from `shape: {'0': old}`.
|
||||||
|
2. In resolution, replace `configuration["shape"]` with `[]any{"new"}` and
|
||||||
|
record normalization at `Keys("shape")`.
|
||||||
|
3. Query final metadata using the two paths from finding 1.
|
||||||
|
|
||||||
|
The old explicit mapping-key path correctly stops being effective, but the newly
|
||||||
|
created sequence index reports `Explicit: true`. That sequence index was never
|
||||||
|
supplied by input. `refreshResolutionFields` reuses the old `FieldInfo` whenever
|
||||||
|
its textual pointer survives; it updates null state without checking whether
|
||||||
|
the old and new containers give that pointer different semantic shapes. Final
|
||||||
|
lookup validates only the new shape, so it cannot recover the lost distinction.
|
||||||
|
|
||||||
|
This can mislead consumers inspecting whether a final value was explicitly
|
||||||
|
configured rather than introduced by application resolution. The converse
|
||||||
|
sequence-to-numeric-key transition has the same underlying collision.
|
||||||
|
|
||||||
|
Potential remediation: retain entry/effective shape evidence across resolution
|
||||||
|
and carry input explicitness forward only for the matching semantic path.
|
||||||
|
Preserve existing explicitness for same-shape paths and the recorded resolution
|
||||||
|
origin; do not clear every descendant's presence merely because an ancestor was
|
||||||
|
normalized. Cover both shape transitions and agreement among `Lookup`,
|
||||||
|
`IsExplicit`, and explanation. Fixing finding 1's callback query alone does not
|
||||||
|
repair this publication path.
|
||||||
|
|
||||||
|
## 3. Metadata history mixes numeric-key and sequence-index definitions
|
||||||
|
|
||||||
|
Priority: P2. Status: open.
|
||||||
|
|
||||||
|
Relevant code:
|
||||||
|
|
||||||
|
- [`Metadata.History`](../../internal/engine/metadata.go), lines 56–65.
|
||||||
|
- `metadataFromDecoded`, `applyOverlayMetadata`, and history event construction
|
||||||
|
in [load_metadata.go](../../internal/engine/load_metadata.go).
|
||||||
|
- Shape-aware replay in [`Inspection.Explain`](../../internal/engine/explain.go).
|
||||||
|
|
||||||
|
The [metadata contract](../config.md#layered-metadata) promises semantic
|
||||||
|
key/index distinctions for historical as well as effective queries. The
|
||||||
|
historical-path index retains both shapes, but `History` uses that index only to
|
||||||
|
check that the query was ever known. It then retrieves the entire history bucket
|
||||||
|
by textual pointer, without selecting the query's shape.
|
||||||
|
|
||||||
|
Reproduction: load `shape: {'0': old}` into `map[string]any`, then apply a CLI
|
||||||
|
override at `Keys("shape")` with `[]any{"new"}`. Both explicit shapes were
|
||||||
|
observed and share `/shape/0`.
|
||||||
|
|
||||||
|
`History(Keys("shape").Index(0))` incorrectly includes the original base
|
||||||
|
definition of the mapping key, and identifies that base origin as the previous
|
||||||
|
origin of the CLI operation. The old input defined a key, not an index. In the
|
||||||
|
same successful result, `Explain` for the explicit index correctly shows only
|
||||||
|
its CLI introduction. `History` for the key and index returns identical records
|
||||||
|
despite their different lifetimes.
|
||||||
|
|
||||||
|
Consequences include false source attribution and disagreement between the two
|
||||||
|
public history interfaces when consumers inspect removed or introduced paths.
|
||||||
|
|
||||||
|
Potential remediation: retain enough container-shape evidence with stored
|
||||||
|
history events, or select relevant events from existing shape-aware replay
|
||||||
|
evidence. Filter exact definitions and previous origins by the queried semantic
|
||||||
|
shape while retaining genuine ancestor replacement/removal events. Filtering
|
||||||
|
only on the current `Change.Path` is insufficient where construction already
|
||||||
|
replaced concrete segment kinds with parsed tokens. Preserve the intentional
|
||||||
|
one-pointer-per-entry `HistoricalPaths` enumeration and contextual behavior of
|
||||||
|
`ParsePath`; neither public contract needs redesign. Cover both direction changes
|
||||||
|
and compare metadata history with explanation.
|
||||||
|
|
||||||
|
## 4. Diagnostic canonicalization breaks composition claimant ordering
|
||||||
|
|
||||||
|
Priority: P3. Status: open.
|
||||||
|
|
||||||
|
Relevant code: `compositionConflictDiagnostic` in
|
||||||
|
[compose.go](../../internal/engine/compose.go) and `canonicalDiagnostics` in
|
||||||
|
[diagnostic.go](../../internal/engine/diagnostic.go), particularly lines 88–92.
|
||||||
|
The latter runs both while constructing `LoadError` and in `WriteDiagnostics`.
|
||||||
|
|
||||||
|
The [strict-composition contract](../config.md#strict-document-composition)
|
||||||
|
specifies the first claim in scoped assembly order as primary and every remaining
|
||||||
|
claim in the same order as related locations.
|
||||||
|
|
||||||
|
A native-filesystem fixture demonstrates the discrepancy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
main.yml imports a.yml, b.yml, c.yml
|
||||||
|
a.yml imports z.yml
|
||||||
|
b.yml, c.yml, and z.yml each define x
|
||||||
|
```
|
||||||
|
|
||||||
|
Acquisition assigns main=source-1, a=source-2, z=source-3, b=source-4,
|
||||||
|
c=source-5. Dependency-ready assembly considers the three conflicting claims
|
||||||
|
in b, c, z order. The composer correctly chooses b as primary and constructs
|
||||||
|
related locations c, z. Generic canonicalization sorts those related locations
|
||||||
|
by acquisition ID, publishing z, c instead. The reproduced public diagnostic
|
||||||
|
has primary source-4 and related source-3, source-5.
|
||||||
|
|
||||||
|
All claimants remain present and the conflict still fails loading, so this is
|
||||||
|
lower impact than the presence/history defects. It nevertheless violates the
|
||||||
|
documented reporting order and gives consumers two different ordering rules
|
||||||
|
within one conflict report.
|
||||||
|
|
||||||
|
Potential remediation: preserve the selected semantic ordering for composition
|
||||||
|
claimant lists through accumulation and rendering, while retaining canonical
|
||||||
|
ordering of diagnostic records and the established sorting of related locations
|
||||||
|
for diagnostics without a semantic-order contract. Add one public failure-path
|
||||||
|
case whose acquisition and claimant orders differ and verify both structured
|
||||||
|
diagnostics and rendered output. No scheduler or precedence change is needed.
|
||||||
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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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.
|
||||||
@@ -1,905 +0,0 @@
|
|||||||
# Sequence Implementation Review Remediation
|
|
||||||
|
|
||||||
Status: findings 1–17 are complete. The algorithm audit below identifies six
|
|
||||||
additional open findings, numbered 18–23. This record introduces no new release
|
|
||||||
gate.
|
|
||||||
|
|
||||||
Reviewed commit: `0be7cdb00eff576fda6b30c0600b2134d4e2e086`.
|
|
||||||
Implementation range: `bc6b977..0be7cdb`.
|
|
||||||
Authority: the [sequence roadmap](sequences.md),
|
|
||||||
[implementation plan](implementation.md),
|
|
||||||
[ADR 0017](../adr/0017-collection-sequence-policy.md), and
|
|
||||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
|
||||||
|
|
||||||
The public facade, shared identity/composition machinery, scoped policies,
|
|
||||||
identified-record inheritance, post-decode CLI phase, and final registered
|
|
||||||
identity validation are present. The findings below concern remaining
|
|
||||||
correctness, contract-alignment, documentation, and scaling gaps.
|
|
||||||
|
|
||||||
Validation on the reviewed commit:
|
|
||||||
|
|
||||||
- `./scripts/check.sh` passed with Go 1.26.10 on Linux.
|
|
||||||
- `go test -race ./...` passed.
|
|
||||||
- Public-API probes outside the repository reproduced findings 1–9. The probes
|
|
||||||
used labeled byte sources, defaults, and an in-memory filesystem; they did not
|
|
||||||
change implementation files or use external services.
|
|
||||||
- Finding 10 follows directly from the maintained text; finding 11 follows from
|
|
||||||
the prefix-scan loop, not a timing comparison or performance threshold.
|
|
||||||
|
|
||||||
Validation after the initial remediation: `./scripts/check.sh` and
|
|
||||||
`go test -race ./...` passed with Go 1.26.10 on Linux. The original public-API
|
|
||||||
probes for findings 1–9 now demonstrate the required corrected behavior.
|
|
||||||
|
|
||||||
Validation after integration remediation: the everyday check, full race suite,
|
|
||||||
and local documentation/public-boundary checks passed with the same toolchain.
|
|
||||||
The original and follow-up public-API probes demonstrate corrected behavior for
|
|
||||||
findings 1–9 and 12–15, including unrelated underlay payload at an old version
|
|
||||||
index. The accumulated-copy correction is verified by code inspection and
|
|
||||||
ownership regressions; the contract corrections are verified against the
|
|
||||||
implemented operations and replay. Each additional finding has its own completion
|
|
||||||
record and commit.
|
|
||||||
|
|
||||||
P1 denotes the highest-priority correctness issue here; P2 denotes normal-priority
|
|
||||||
work needed to complete the selected behavior. Preserve the agreed scope while
|
|
||||||
fixing these issues. In particular, do not resolve failures by restricting valid
|
|
||||||
identity fields, requiring repeated declarations, changing merge semantics, or
|
|
||||||
adding limits.
|
|
||||||
|
|
||||||
## 1. Preserve Retained Explicit Values In Nested CLI Mapping Assignments — P1
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: override metadata now reads the actual target subtree through one
|
|
||||||
shared lookup. Ordinary/layered regressions preserve retained values, including
|
|
||||||
zero and false, their explicit origins, and absence-based resolver behavior.
|
|
||||||
Verified `go test ./internal/engine -run 'Override|CLI' -count=1` and
|
|
||||||
`./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owner: [`applyDecodedOverrides`](../../internal/engine/override_decode.go),
|
|
||||||
especially lines 59–60 and the presence/origin update helpers.
|
|
||||||
|
|
||||||
The helpers receive the complete configuration projection as `existing`, but
|
|
||||||
interpret it as the subtree at the override path. A partial nested mapping
|
|
||||||
therefore treats retained input children as absent and drops their presence and
|
|
||||||
origins. `fillDefaultOrigins` subsequently labels those retained values as defaults.
|
|
||||||
|
|
||||||
Reproduction: load the following into ordinary nested Go structs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
settings:
|
|
||||||
nested:
|
|
||||||
a: 1
|
|
||||||
b: 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply an override at `[settings]` with value
|
|
||||||
`map[string]any{"nested": map[string]any{"a": 3}}`. The typed value of `b`
|
|
||||||
initially remains 2, but `Metadata.Lookup([settings,nested,b])` reports
|
|
||||||
`Explicit: false` and a default origin. Calling `Default` for that field in
|
|
||||||
application resolution then changes the explicitly supplied 2 to the fallback
|
|
||||||
value. This violates presence-based defaulting, not merely presentation.
|
|
||||||
|
|
||||||
Required remediation: use the actual existing target subtree when updating
|
|
||||||
presence/origins, preserving retained descendants and removing genuinely replaced
|
|
||||||
ones. Keep override batches detached and preserve the selected post-decode order.
|
|
||||||
Add regressions for nested partial mappings, retained zero/false values, metadata,
|
|
||||||
and an absence-based resolver default that must not overwrite retained input.
|
|
||||||
|
|
||||||
## 2. Read Upsert Origins At Input Paths And Write Them At Output Paths — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: sequence origin copying now separates incoming and effective paths
|
|
||||||
for precedence and inheritance. A regression covers reordered matches, appended
|
|
||||||
records, composite origins, and actual resource reads from distinct declaring
|
|
||||||
directories. Verified focused origin/inheritance/overlay/provenance tests,
|
|
||||||
`go test -race ./internal/engine`, and `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`applyOverlaySequenceEvidence`](../../internal/engine/overlay.go),
|
|
||||||
particularly lines 369 and 381, and the analogous sequence-origin copying in
|
|
||||||
[`inheritance_collection.go`](../../internal/engine/inheritance_collection.go).
|
|
||||||
|
|
||||||
The overlay helper supplies an output-index path to an origin lookup whose keys
|
|
||||||
are still incoming-index paths. When those positions differ and another incoming
|
|
||||||
record occupies the output index, metadata takes that other record's source
|
|
||||||
location. The same defect was reproduced through mapping-sibling inheritance;
|
|
||||||
its helper uses the same single-path copying pattern.
|
|
||||||
|
|
||||||
Reproduction: an underlay supplies IDs `[a,b]`; the main source declares `key: id`
|
|
||||||
and supplies `[b*,a*]`. The result is correctly `[a*,b*]`, but the value origin
|
|
||||||
for final `a*` is the source line of `b*`, and vice versa. In the probe the two
|
|
||||||
value lines were 7 and 9; final index 0 incorrectly reported line 7 instead of 9.
|
|
||||||
With separately authored records this can select a different source/context.
|
|
||||||
|
|
||||||
Required remediation: use the operation's input-to-output projection for origin
|
|
||||||
subtree reads as well as writes. Preserve composite contributing origins, not
|
|
||||||
just node-location fallbacks. Test reordered upserts and appended records with
|
|
||||||
distinct source files/locations, including nested fields, inheritance operations,
|
|
||||||
and declaring resource contexts.
|
|
||||||
|
|
||||||
## 3. Apply Tier Sequence Operations To Historical Contributions — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: per-source history now composes strict item projections with the
|
|
||||||
tier operation, including nested application prefixes and local sequence replay.
|
|
||||||
Regressions cover all three operations, multiple peers, retained records, removed
|
|
||||||
fields, source attribution, and a subsequent overlay without a false decoding
|
|
||||||
event. Verified focused history tests, `go test -race ./internal/engine`, and
|
|
||||||
`./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owner: [`applyOverlayWithCollectionPolicy`](../../internal/engine/overlay.go),
|
|
||||||
lines 76–84, and existing sequence-history projection/replay.
|
|
||||||
|
|
||||||
For composed tiers, `applications` is nonempty. This branch appends the tier's
|
|
||||||
original strict-composition applications without incorporating the newly computed
|
|
||||||
precedence sequence descriptors. Replay consequently performs a whole-list
|
|
||||||
replacement or strict append rather than the actual cross-tier upsert/append.
|
|
||||||
The later decoding reconciliation conceals the mismatch by attributing a
|
|
||||||
structural combination to typed defaults/decoding.
|
|
||||||
|
|
||||||
Reproduction: lower `[a,b]` and higher `[b*,a*]` correctly yield `[a*,b*]`.
|
|
||||||
`Explain([items])` instead shows the main contribution producing `[b*,a*]`, then
|
|
||||||
a `decoding` event reordering it to `[a*,b*]` with the reason "applied retained
|
|
||||||
defaults and canonical typed decoding". No decoder performed that reordering.
|
|
||||||
|
|
||||||
Required remediation: project each retained per-source application through the
|
|
||||||
tier's selected operation while keeping peer composition distinct from tier
|
|
||||||
precedence. Do not collapse authored sources or fake decoding events. Cover
|
|
||||||
upsert, append, unmatched retained records, reordered matches, removed fields,
|
|
||||||
multiple higher-tier peers, and a subsequent selected overlay. Replay should
|
|
||||||
agree with structural assembly before real decoding reconciliation.
|
|
||||||
|
|
||||||
## 4. Reproject Nested Source Destinations After Cross-Tier Item Movement — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: layered assembly projects incoming source destinations and version
|
|
||||||
classification evidence with the same item assignments used for values/history.
|
|
||||||
Earlier contributions retain their historical coordinates, physical identities
|
|
||||||
and contexts. Main/underlay regressions cover reordered upserts, append, replace,
|
|
||||||
nested helpers, and source-local version evidence. Verified focused destination,
|
|
||||||
origin and source-version tests, focused race coverage, and `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`projectAcquiredDestinations`](../../internal/engine/imports.go) and the
|
|
||||||
sequence-operation handoff in [`assembleLayeredInput`](../../internal/engine/load_request.go).
|
|
||||||
|
|
||||||
Source destinations are projected while composing their own tier, but are not
|
|
||||||
reprojected when that tier's records move to accumulated-list positions. Nested
|
|
||||||
helper source records can therefore point at a different final record.
|
|
||||||
|
|
||||||
Reproduction: lower `items` contains `[a,b]`. Main imports a single `b` record
|
|
||||||
with `placement: sequence-item` into `[items]`; that record imports a helper
|
|
||||||
mapping supplying its value. Upsert retains `a` at index 0 and places the higher
|
|
||||||
`b` at index 1. `Sources()` nevertheless reports the surviving helper's destination
|
|
||||||
as `/items/0`, its tier-local position, rather than `/items/1`.
|
|
||||||
|
|
||||||
Required remediation: carry the sequence operation's projection through nested
|
|
||||||
source-destination/evidence updates at precedence boundaries. Keep physical
|
|
||||||
source identity and passive resource contexts unchanged, and preserve historical
|
|
||||||
meaning for displaced contributions rather than relabeling them as survivors.
|
|
||||||
Test a retained lower record followed by an imported higher record, reordered
|
|
||||||
matches, and nested helpers. Check version-evidence projection where applicable.
|
|
||||||
|
|
||||||
## 5. Honor Identity Reset On Actual Inheritance Replacement — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: inheritance resolves identity against actual child-value presence,
|
|
||||||
discarding parent YAML identity for an explicit replacement while preserving
|
|
||||||
agreement for retained values and authoritative application constraints. Added
|
|
||||||
policy-state and public-load regressions for changed/unkeyed/non-sequence
|
|
||||||
replacements, application agreement/conflict, and mode-only/append/upsert cases.
|
|
||||||
Verified focused inheritance tests and `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`inheritanceCollectionResolution` and `mergeInheritanceCollectionPolicies`](../../internal/engine/inheritance_collection.go),
|
|
||||||
especially lines 79–80 and 121.
|
|
||||||
|
|
||||||
These paths reconcile the parent's YAML identity before accounting for actual
|
|
||||||
replacement, and treat the parent's collection as retained even when the child
|
|
||||||
supplies a replacing value. This is stronger than the selected identity lifetime
|
|
||||||
and differs from source-tier replacement.
|
|
||||||
|
|
||||||
Reproduction:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [base, items]
|
|
||||||
key: id
|
|
||||||
- destination: [child, items]
|
|
||||||
key: name
|
|
||||||
mode: replace
|
|
||||||
base:
|
|
||||||
items:
|
|
||||||
- id: a
|
|
||||||
value: parent
|
|
||||||
child:
|
|
||||||
extends: base
|
|
||||||
items:
|
|
||||||
- name: b
|
|
||||||
value: child
|
|
||||||
```
|
|
||||||
|
|
||||||
Loading fails with `sequence.conflict` at `/child/items`. It should succeed:
|
|
||||||
actual whole-sequence replacement may discard the parent's YAML identity and
|
|
||||||
establish `name`. Authoritative application constraints must still apply.
|
|
||||||
|
|
||||||
Required remediation: distinguish actual replacement from a mode-only declaration
|
|
||||||
before identity agreement and transfer. Test a changed key, replacement with no
|
|
||||||
new key becoming unkeyed, permitted non-sequence replacement, and an agreeing or
|
|
||||||
conflicting application registration. Preserve rejection of identity changes
|
|
||||||
without a replacement value and during append/upsert.
|
|
||||||
|
|
||||||
## 6. Enforce Collection Operations Beneath Newly Added Ancestors — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: copying a mapping now visits nested collection operations with absent
|
|
||||||
lower values, preserving ordinary ancestor removal and whole-record semantics.
|
|
||||||
Incoming record-owned policy transfers through the same operation. Regressions
|
|
||||||
cover absent/empty/null ancestors, identity and kind failures, empty and valid
|
|
||||||
initialization, and later inheritance using an imported item's nested identity.
|
|
||||||
Corrected an older test that deferred the required error until a later tier.
|
|
||||||
Verified focused merge/collection/inheritance tests, focused ownership race
|
|
||||||
coverage, and `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`mergeRecursiveMappings`](../../internal/engine/recursive_merge.go) and
|
|
||||||
[`mergeOverlayMappingsWithCollectionPolicy`](../../internal/engine/overlay.go).
|
|
||||||
|
|
||||||
When an incoming ancestor mapping is absent below, the recursive merger clones
|
|
||||||
the entire subtree and invokes only its ancestor-level added hook. Nested
|
|
||||||
collection resolutions are not visited. This bypasses their operation rules;
|
|
||||||
the subsequent declaration-only policy update does not replace those checks.
|
|
||||||
|
|
||||||
Reproduction: combine a lower `{}` with this main tier:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [group, items]
|
|
||||||
mode: append
|
|
||||||
group:
|
|
||||||
items:
|
|
||||||
- id: a
|
|
||||||
```
|
|
||||||
|
|
||||||
Loading succeeds despite append having no identity definition. Placing the same
|
|
||||||
sequence directly at `[items]` correctly fails. The validity of an operation
|
|
||||||
must not depend on whether an enclosing mapping already existed.
|
|
||||||
|
|
||||||
Required remediation: account for applicable nested collection policies when
|
|
||||||
adding/copying an ancestor subtree, including owned-policy transfer. Test absent
|
|
||||||
and present enclosing mappings, append/upsert without identity, explicit empty
|
|
||||||
lists, invalid non-sequence contributions, and valid identified initialization.
|
|
||||||
Do not turn this into a general path-existence or shape constraint: ordinary
|
|
||||||
ancestor replacement/removal retains its documented meaning.
|
|
||||||
|
|
||||||
## 7. Validate Final Typed Identity Fields As Payload, Not Directives — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: shared identity lookup now distinguishes structural records from
|
|
||||||
effective payload/reference mappings. Final validation includes literal `extends`
|
|
||||||
fields while structural source checks still exclude actual directives. Facade
|
|
||||||
regressions cover defaults and recorded resolution, string/null components, and
|
|
||||||
duplicate rejection. Verified those regressions and existing structural identity
|
|
||||||
and inheritance coverage through `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`validateFinalSequenceIdentities`](../../internal/engine/final_identity.go)
|
|
||||||
and [`newSequenceMappingIndex`](../../internal/engine/sequence_identity.go).
|
|
||||||
|
|
||||||
The final validator reuses structural record lookup, which excludes every
|
|
||||||
`extends` entry. At this phase there is no structural processing: typed defaults
|
|
||||||
and application resolution can produce ordinary payload with that literal name.
|
|
||||||
The roadmap expressly distinguishes structural-control exclusion from final
|
|
||||||
typed-payload validation; this reuse introduces an extra restriction.
|
|
||||||
|
|
||||||
Reproduction: a defaults-only `Catalog` with `Items []map[string]any` containing
|
|
||||||
`{"extends": "application-data"}`, registered with
|
|
||||||
`SequenceIdentityAt(Keys("items"), "extends")`, fails with a missing identity
|
|
||||||
field at `/items/0/extends`, even though the effective field is present.
|
|
||||||
|
|
||||||
Required remediation: distinguish structural-payload lookup from final payload
|
|
||||||
lookup. Preserve rejection when a source tries to use an actual consumed
|
|
||||||
directive as identity. Test final fields supplied by defaults and recorded
|
|
||||||
resolution, including a null component, without adding a broader name ban.
|
|
||||||
|
|
||||||
## 8. Retain Nonfatal Main-Version Diagnostics In Layered Loads — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: layered assembly returns nonfatal main-version diagnostics on success
|
|
||||||
and alongside later failures; the shared caller stops only for errors. Regression
|
|
||||||
coverage verifies ordinary/layered warning parity, exactly one checker call, and
|
|
||||||
warnings retained with later structural or decoding errors. Verified through
|
|
||||||
`./scripts/check.sh`, including existing source-version ordering coverage.
|
|
||||||
|
|
||||||
Owner: [`assembleLayeredInput`](../../internal/engine/load_request.go), lines 143–152.
|
|
||||||
|
|
||||||
The moved default-mode version-check call returns its diagnostics only when they
|
|
||||||
contain errors. Warning-only diagnostics are discarded. Ordinary loading still
|
|
||||||
retains the same warnings.
|
|
||||||
|
|
||||||
Reproduction: a `VersionRule.Check` that calls
|
|
||||||
`validation.Warning(Keys("version"), "test warning")` and returns nil yields one
|
|
||||||
`version.warning` through ordinary `Load`, but zero diagnostics through
|
|
||||||
`LoadLayered` with the same main input and default source-version mode.
|
|
||||||
|
|
||||||
Required remediation: carry warnings from the single main-tier check into the
|
|
||||||
shared diagnostic accumulator on successful layered loads. Preserve checker
|
|
||||||
ownership, invocation count, and the selected acquisition/composition ordering.
|
|
||||||
Add ordinary/layered parity and warning-plus-later-error regressions.
|
|
||||||
|
|
||||||
## 9. Identify Conflicting Application Registrations In Diagnostics — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: definition validation retains original one-based registration
|
|
||||||
positions and names both positions in each overlap diagnostic. No input path or
|
|
||||||
new public diagnostic structure is invented. A mixed-selector regression verifies
|
|
||||||
multiple conflicts even after an earlier invalid registration is skipped.
|
|
||||||
Verified registration/facade coverage through `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owner: [`compileSequenceIdentityPolicy`](../../internal/engine/sequence_registration.go),
|
|
||||||
lines 119–128.
|
|
||||||
|
|
||||||
Incompatible dynamic overlaps produce only a generic `options.invalid` message,
|
|
||||||
an empty configuration path, and no related information. Neither registration
|
|
||||||
is identified. The roadmap requires definition-time errors to identify the
|
|
||||||
conflicting registrations without inventing a concrete input destination.
|
|
||||||
|
|
||||||
Reproduction: register `collections -> AnyMappingKey -> items` using `id`, and
|
|
||||||
`collections -> foo -> items` using `name`. Even with `NoSource`, the error is
|
|
||||||
only "overlapping sequence registrations must use the same identity fields".
|
|
||||||
With multiple registrations the caller cannot identify the conflicting pair.
|
|
||||||
|
|
||||||
Required remediation: retain original registration positions and identify both
|
|
||||||
in safe definition diagnostics, for example through positional message context.
|
|
||||||
No new public diagnostic structure is required solely for this. Test mixed exact
|
|
||||||
and dynamic registrations and multiple conflicts, without asserting complete
|
|
||||||
English wording or exposing identity values.
|
|
||||||
|
|
||||||
## 10. Remove Stale Public Contract Descriptions — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: the public definition comment now describes final registered-identity
|
|
||||||
validation and its shape/lifetime boundaries. Selected-overlay prose explicitly
|
|
||||||
separates ordinary replacement from collection policy and links to the canonical
|
|
||||||
operation/presence tables. Verified against the implementation, local-link tests,
|
|
||||||
and `git diff --check`; no runtime behavior changed.
|
|
||||||
|
|
||||||
Owners: [`Definition.SequenceIdentities`](../../lifecycle.go), lines 18–21, and
|
|
||||||
the [selected-overlay contract](../config.md#selected-overlays).
|
|
||||||
|
|
||||||
The exported definition-field comment still says registrations do not validate
|
|
||||||
identities introduced by defaults, CLI, or callbacks. Final validation is now
|
|
||||||
implemented and the registration constructors correctly document it.
|
|
||||||
|
|
||||||
The selected-overlay table and following paragraph also still unconditionally
|
|
||||||
describe sequences as atomic replacements and `[]` as clearing. A later paragraph
|
|
||||||
describes collection operations, but does not make the earlier unconditional
|
|
||||||
claims accurate: identified collections infer upsert, and `[]` then contributes
|
|
||||||
no records rather than clearing.
|
|
||||||
|
|
||||||
Required remediation: make the symbol comment describe its implemented final
|
|
||||||
guarantee and boundaries, and qualify the overlay table/paragraph for collection
|
|
||||||
policy. Link to the canonical collection tables rather than duplicating them.
|
|
||||||
Keep YAML-only enforcement distinct from registered final validation.
|
|
||||||
|
|
||||||
## 11. Remove The Quadratic Prefix Scan In Item Policy Projection — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: assigned sequence ranges now supply item offsets directly. Handoffs
|
|
||||||
without a range lazily accumulate prefix lengths, visiting each preceding
|
|
||||||
contribution once rather than rescanning every prefix. Regression coverage keeps
|
|
||||||
inline and policy-less contributions in the correct positions with complete,
|
|
||||||
partial, or absent ranges. Existing ownership/placement tests remain intact.
|
|
||||||
Verified focused item-policy tests, `./scripts/check.sh`, `go test -race ./...`,
|
|
||||||
and inspection of the remaining error-only prefix-scan callers. No new benchmark
|
|
||||||
fixture or performance threshold was needed for this bounded change.
|
|
||||||
|
|
||||||
Owner: [`projectNestedCollectionPolicies`](../../internal/engine/imports.go),
|
|
||||||
especially line 968; the scanned helper is `priorSequenceLength` in the same file.
|
|
||||||
|
|
||||||
For every item with local collection policy, the loop scans all preceding
|
|
||||||
contributions to calculate `itemIndex`, then overwrites that result when the
|
|
||||||
already-indexed sequence range is available. For N such items this performs
|
|
||||||
`0 + 1 + ... + (N-1)` contribution visits, excluding path lookup costs, despite
|
|
||||||
the common case already having the exact assigned index. This is introduced
|
|
||||||
quadratic work in the feature's independently authored-record path.
|
|
||||||
|
|
||||||
Required remediation: use the existing assigned range first. If a genuine
|
|
||||||
fallback is necessary, maintain per-destination prefix lengths in one pass
|
|
||||||
rather than rescanning every prefix. Preserve scope and ordering semantics;
|
|
||||||
do not add limits, pooling, or a new public cache. Keep ownership/placement
|
|
||||||
regressions and add a representative many-item/local-policy benchmark only if
|
|
||||||
needed to exercise the path. A wall-clock threshold is not required.
|
|
||||||
|
|
||||||
## Integration Audit Baseline And Scope
|
|
||||||
|
|
||||||
Reviewed commit: `8d40983271de5239312eb0a2896737be88c5abdc`.
|
|
||||||
|
|
||||||
This follow-up reviews the interaction of collection policy, identified-record
|
|
||||||
inheritance, dynamic registrations, and post-decode CLI assignment with existing
|
|
||||||
source placement, source-version checking, typed decoding, inspection, resource
|
|
||||||
contexts, and ownership boundaries. The accepted ADRs and current configuration
|
|
||||||
contract remain the authority; the findings require integration corrections,
|
|
||||||
not additional consumer restrictions or new feature choices.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
- `./scripts/check.sh` and `go test -race ./...` pass with Go 1.26.10 on Linux.
|
|
||||||
- Isolated public-API probes outside the repository reproduce findings 12–15.
|
|
||||||
They use labeled byte sources and an in-memory filesystem, with no external
|
|
||||||
services, real credentials, or repository implementation changes.
|
|
||||||
- Finding 16 follows from the per-item deep-copy loop; no timing claim or
|
|
||||||
performance threshold is needed. Finding 17 follows from maintained contract
|
|
||||||
text that contradicts the implemented collection operations.
|
|
||||||
- Positive comparisons include sequence-valued identity replacement, ancestor
|
|
||||||
retirement through source tiers, quoted versions after item movement, and an
|
|
||||||
append overlay that preserves the main record's version.
|
|
||||||
|
|
||||||
## 12. Complete Identity Retirement Across Replacement Shapes — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: source composition and inheritance now prepare eligible lower policy
|
|
||||||
from actual replacement paths before agreement. Removed identity and record state
|
|
||||||
cannot constrain a new pending key or reappear in later inheritance. Shared mode
|
|
||||||
selection preserves explicit-mode persistence and authoritative registrations.
|
|
||||||
Regressions cover null/scalar/mapping/list replacement, ancestor removal with a
|
|
||||||
new key, reintroduction, and retained/application-constrained conflicts. Verified
|
|
||||||
focused retirement/inheritance tests, engine ownership race coverage, and
|
|
||||||
`./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`resolveStrictCollectionPolicies`](../../internal/engine/compose.go),
|
|
||||||
especially its `sequencePresent` reset condition;
|
|
||||||
[`mergeInheritanceCollectionPolicies`](../../internal/engine/inheritance_collection.go);
|
|
||||||
and ancestor-replacement handling in
|
|
||||||
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go).
|
|
||||||
|
|
||||||
Policy agreement and retirement still treat some actual replacements as retained
|
|
||||||
collections. This has three observable effects under the same selected lifetime
|
|
||||||
rule:
|
|
||||||
|
|
||||||
1. Source precedence accepts a replacement sequence with a new YAML key, but
|
|
||||||
rejects the equivalent replacement by null or a scalar. For example, a lower
|
|
||||||
tier declaring `key: id` for `[items]` with `items: [{id: old}]`, followed by
|
|
||||||
a main tier declaring `key: name, mode: replace` with `items: null`, fails with
|
|
||||||
`sequence.conflict` at `/items`. Replacing null with `42` also fails; replacing
|
|
||||||
it with `[{name: new}]` succeeds. Null/scalar replacement should discard the
|
|
||||||
lower identity and retain the newly declared key as pending policy, subject
|
|
||||||
to the application's schema and constraints.
|
|
||||||
2. Removing an ancestor while declaring a new pending key for its removed
|
|
||||||
descendant fails before the replacement occurs. A lower
|
|
||||||
`group: {items: [{id: old}]}` with key `id`, followed by `group: null` and a
|
|
||||||
root declaration of key `name` for `[group, items]`, fails at `/group/items`.
|
|
||||||
The analogous `base`/`child` inheritance case fails at `/child/group/items`.
|
|
||||||
The old collection is being removed rather than retained.
|
|
||||||
3. Inheritance can resurrect removed identity even without a conflicting new
|
|
||||||
declaration. This input fails with a missing `id` at
|
|
||||||
`/grandchild/group/items/0/id`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
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}]}
|
|
||||||
```
|
|
||||||
|
|
||||||
The same remove/reintroduce operations through source tiers succeed. The
|
|
||||||
inherited descendant identity remains in policy state after its enclosing
|
|
||||||
mapping is removed, so the grandchild incorrectly receives the old key.
|
|
||||||
|
|
||||||
Required remediation: account for actual value and ancestor replacement before
|
|
||||||
deciding which lower identity participates in agreement, and retire removed
|
|
||||||
descendant/record-owned state during inheritance as during source precedence.
|
|
||||||
Retained inheritance templates must not reestablish discarded identity later.
|
|
||||||
Preserve authoritative application constraints, independently owned mode
|
|
||||||
persistence, pending declarations, and rejection of key changes when values are
|
|
||||||
genuinely retained. Cover direct non-sequence replacement, ancestor removal with
|
|
||||||
a new pending key, and removal followed by reintroduction across both contexts.
|
|
||||||
Do not require repeated keys or narrow permitted null/scalar shapes to avoid
|
|
||||||
these cases.
|
|
||||||
|
|
||||||
## 13. Keep Metadata History In Post-Decode CLI Order — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: metadata history is assembled from the pre-CLI source view, with CLI
|
|
||||||
events appended separately; inheritance is projected before CLI and resolution.
|
|
||||||
Effective fields still describe post-CLI values. Regressions verify chronological
|
|
||||||
origins and resolver dependencies for ordinary/layered mapping and record
|
|
||||||
inheritance, including equal-valued assignments. Verified history/override
|
|
||||||
coverage and `./scripts/check.sh`.
|
|
||||||
|
|
||||||
Owners: [`historyWithInheritance`](../../internal/engine/inheritance_history.go)
|
|
||||||
and the source/CLI metadata handoff in
|
|
||||||
[`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go).
|
|
||||||
|
|
||||||
The value explanation follows the new lifecycle, but `Metadata.History` still
|
|
||||||
inserts inheritance after stored CLI changes. `historyWithInheritance` splits
|
|
||||||
only at `LayerResolution`, reflecting the former CLI-before-inheritance order.
|
|
||||||
The reconstructed source view can also append an authored source origin after
|
|
||||||
the CLI event.
|
|
||||||
|
|
||||||
Reproduction: load `base: {value: parent}` and
|
|
||||||
`child: {extends: base, value: authored}`, then override `[child, value]` with
|
|
||||||
`"cli"`. The final value and `Metadata.Lookup` correctly report CLI provenance.
|
|
||||||
`Explain` reports source → inheritance → CLI. `Metadata.History` instead reports
|
|
||||||
source → CLI → source → inheritance and ends with a source/inheritance origin.
|
|
||||||
This violates chronological metadata history and disagrees with the effective
|
|
||||||
origin and the value explanation.
|
|
||||||
|
|
||||||
Required remediation: assemble metadata history in the implemented source,
|
|
||||||
inheritance, CLI, and resolution order, preserving the real origins and
|
|
||||||
dependencies of those events. Cover ordinary/layered loading, mapping and record
|
|
||||||
inheritance, equal-valued CLI writes, and a later recorded resolver change at the
|
|
||||||
appropriate existing test owners. Do not impose a new exact event-count or
|
|
||||||
message-format contract.
|
|
||||||
|
|
||||||
## 14. Carry Main-Version Coordinates Through Sequence Movement — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: the effective version rule now follows the main declaration through
|
|
||||||
strict placement, tier combination, and inheritance without changing source-local
|
|
||||||
checker/evidence paths. Underlay and overlay ownership checks use assigned record
|
|
||||||
positions, including whole-record removal; integer conversion remains confined
|
|
||||||
to the actual main header. Regressions cover mounted/indexed paths, appended and
|
|
||||||
matched records, integer/string parity, unrelated version payload, imported
|
|
||||||
underlay claims, and inheritance. Corrected an older ownership fixture to target
|
|
||||||
the actual matching main record. Verified source-version regressions,
|
|
||||||
`./scripts/check.sh`, and `go test -race ./internal/engine`.
|
|
||||||
|
|
||||||
Owners: [`effectiveSourceVersionRuleForPlacement`](../../internal/engine/source_version.go),
|
|
||||||
the main-tier projection handoff in
|
|
||||||
[`assembleLayeredInput`](../../internal/engine/load_request.go), and use of
|
|
||||||
the effective rule in [`executeLoadRequest`](../../internal/engine/load.go).
|
|
||||||
|
|
||||||
Source destinations and version-evidence sensitivity paths follow item movement,
|
|
||||||
but the rule used for integer-version decoding retains the original item index.
|
|
||||||
Consequently an accepted integer header can fail decoding after an otherwise
|
|
||||||
valid upsert placement.
|
|
||||||
|
|
||||||
Reproduction with the public API:
|
|
||||||
|
|
||||||
- Main input is `id: main` and `version: 4`, placed as one sequence item at
|
|
||||||
`Keys("items")`.
|
|
||||||
- A lower input contributes the sequence item `id: lower` at the same destination.
|
|
||||||
- Register identity `id` for `[items]`, select `SourceVersionModeIfPresent`, and
|
|
||||||
use `VersionRule.Path = Keys("version")` with an accepting checker.
|
|
||||||
- Decode into records with `ID string` and `Version string`.
|
|
||||||
|
|
||||||
The checker accepts `4` exactly once. Upsert places the main record at index 1,
|
|
||||||
but decoding fails with `decode.type` at `/items/1/version`. The integer-to-string
|
|
||||||
exception is still tied to `/items/0/version`. Quoting the identical version as
|
|
||||||
`'4'` succeeds. A source-local indexed header path, without item placement,
|
|
||||||
reproduces the same problem when its record moves during upsert.
|
|
||||||
|
|
||||||
Required remediation: carry the main declaration's effective coordinates through
|
|
||||||
the existing item projections into decoding and audit the associated ownership
|
|
||||||
protection handoffs for the same stale-coordinate assumption. Preserve
|
|
||||||
source-local checker/evidence paths, checker invocation count, main-only
|
|
||||||
ownership, and the narrow integer-to-string exception. Cover moved existing and
|
|
||||||
newly appended main records, integer/string parity, and protection of the actual
|
|
||||||
main header after movement. Do not prohibit supported indexed paths or mounted
|
|
||||||
main records as a workaround.
|
|
||||||
|
|
||||||
## 15. Exclude Parent Identity From Inherited History Payload — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: retained record-inheritance operations now exclude parent identity
|
|
||||||
from their payload and origin projection. Replay preserves the child's own
|
|
||||||
identity before taking field, record, sequence, or root snapshots, without
|
|
||||||
inventing inheritance events for retained identity fields. Regressions cover
|
|
||||||
single and compound identities, explicit child provenance, ordinary inherited
|
|
||||||
payload, and the parent's original history. Verified inheritance/inspection
|
|
||||||
coverage, `./scripts/check.sh`, and `go test -race ./internal/engine`.
|
|
||||||
|
|
||||||
Owners: inheritance operation construction in
|
|
||||||
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go),
|
|
||||||
[`appendInheritanceExplanation`](../../internal/engine/inheritance_explain.go),
|
|
||||||
and transferred-origin projection in
|
|
||||||
[`inheritance_history.go`](../../internal/engine/inheritance_history.go).
|
|
||||||
|
|
||||||
Identified-record value construction excludes the parent's identity fields, but
|
|
||||||
the retained history operation still supplies the complete parent record and
|
|
||||||
its identity origins to the older mapping-inheritance replay machinery.
|
|
||||||
|
|
||||||
Reproduction:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences: [{destination: [items], key: id}]
|
|
||||||
items:
|
|
||||||
- id: base
|
|
||||||
value: parent
|
|
||||||
- id: child
|
|
||||||
extends: {id: base}
|
|
||||||
```
|
|
||||||
|
|
||||||
The effective child ID and origin are correct. However,
|
|
||||||
`Explain(Keys("items").Index(1).Key("id"))` reports `child` → `base` → `child`,
|
|
||||||
attributing the intermediate value to an inheritance operation and the parent's
|
|
||||||
ID location. `Metadata.History` likewise claims an inherited parent-ID
|
|
||||||
contribution. That identity is expressly excluded from inherited payload; it was
|
|
||||||
never an applied value at the child path.
|
|
||||||
|
|
||||||
Required remediation: make history replay and transferred-origin projection use
|
|
||||||
the same identity exclusion as value construction. Preserve the child's authored
|
|
||||||
identity and presence, ordinary inherited payload history, and the parent record's
|
|
||||||
own source history at its original path. Cover compound identities and identity
|
|
||||||
fields alongside inherited payload at both record and ancestor queries; keep the
|
|
||||||
existing inspection API and sensitivity behavior.
|
|
||||||
|
|
||||||
## 16. Avoid Deep-Copying Accumulated Item Policies Per Record — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: item projection now takes one owned policy copy per destination,
|
|
||||||
updates that local state incrementally, and stores it once after projection.
|
|
||||||
Incoming scopes still cross detached ownership boundaries. Inspection confirms
|
|
||||||
that accumulated record scopes are no longer deep-copied per item; no timing
|
|
||||||
threshold or benchmark campaign was needed. Existing stable-index, sparse-range,
|
|
||||||
fallback-offset, and ownership coverage passes, with a new regression for
|
|
||||||
interleaved destinations, distinct record owners, and sibling/cross-load mutation
|
|
||||||
independence. Verified focused item-policy tests, `./scripts/check.sh`, and
|
|
||||||
`go test -race ./...`.
|
|
||||||
|
|
||||||
Owners: [`projectNestedCollectionPolicies`](../../internal/engine/imports.go)
|
|
||||||
and its use of copying `collectionPolicyScope.at` / `set` helpers in
|
|
||||||
[`collection_policy_state.go`](../../internal/engine/collection_policy_state.go).
|
|
||||||
|
|
||||||
The prefix-scan correction in finding 11 is present, but a separate quadratic
|
|
||||||
cost remains on the same independently authored-record path. For every imported
|
|
||||||
item with local policy, `at` deep-clones the destination's accumulated record
|
|
||||||
order and every retained record's nested scope. After adding one owner, `set`
|
|
||||||
deep-clones the growing policy again. With N fixed-size item-local policies at
|
|
||||||
one destination, this copies approximately `1 + 2 + ... + N` retained scopes,
|
|
||||||
even when every assigned sequence range is available.
|
|
||||||
|
|
||||||
This follows directly from the loop and `cloneCollectionPolicy`; no busy-host
|
|
||||||
timing comparison is required. It is an integration cost from reusing detached
|
|
||||||
read/write helpers within private incremental construction, rather than from
|
|
||||||
the required final ownership copies.
|
|
||||||
|
|
||||||
Required remediation: construct each destination's owned record-policy state
|
|
||||||
without repeatedly cloning all earlier records, retaining necessary copies at
|
|
||||||
ownership boundaries. Preserve record ordering, distinct owners, sparse assigned
|
|
||||||
positions, fallback offsets, and cross-load detachment. Reuse the existing item
|
|
||||||
policy and ownership tests; add allocation/scaling evidence only where useful.
|
|
||||||
Do not introduce public caches, pooling, new caps, or performance thresholds.
|
|
||||||
|
|
||||||
## 17. Reconcile Legacy Sequence-History Contract Text — P2
|
|
||||||
|
|
||||||
Status: [x] Complete.
|
|
||||||
|
|
||||||
Completion: metadata and explanation prose now describe history through the
|
|
||||||
selected collection operation, assigned item positions, and displaced payload.
|
|
||||||
The text distinguishes sequence snapshots and atomic diffs from append/upsert
|
|
||||||
combination and links to the canonical operation tables. Whole-record upsert,
|
|
||||||
existing queries, and sensitivity guarantees are unchanged. Verified against
|
|
||||||
replay/combination code, local documentation-link tests, and `git diff --check`;
|
|
||||||
the unchanged runtime is covered by the preceding everyday and full race checks.
|
|
||||||
|
|
||||||
Owner: the metadata/provenance and explanation sections of
|
|
||||||
[`docs/config.md`](../config.md#layered-metadata).
|
|
||||||
|
|
||||||
The metadata section still says that a later tier supplying a sequence records
|
|
||||||
replacement at the sequence path and that history does not imply identity-based
|
|
||||||
merging across tiers. That describes the former unconditional replacement model,
|
|
||||||
not the implemented append/upsert history or the collection operation tables in
|
|
||||||
the same document. The explanation paragraph about atomic sequences also needs
|
|
||||||
review so readers can distinguish snapshot/diff granularity from collection
|
|
||||||
combination semantics.
|
|
||||||
|
|
||||||
Required remediation: describe history for the selected collection operation and
|
|
||||||
link to the canonical operation tables. Preserve the existing atomic sequence
|
|
||||||
snapshot/diff behavior and whole-record upsert semantics; correcting the prose
|
|
||||||
does not authorize recursive record-field merging, a new query API, or broader
|
|
||||||
history guarantees.
|
|
||||||
|
|
||||||
## Algorithm Audit Baseline And Scope
|
|
||||||
|
|
||||||
Reviewed commit: `c73daf891d0f29c96f7026bdabcccb571f44a751`.
|
|
||||||
Feature baseline: `4f941f8ba4c9a9ed0afa87b07fe6ba29f131d1c6`.
|
|
||||||
|
|
||||||
This audit examines the sequence identity/operation, scoped-policy, inheritance,
|
|
||||||
inspection, and post-decode CLI algorithms, including the completed corrections
|
|
||||||
above. The intervening discovery roadmap commits add no implementation to audit.
|
|
||||||
The accepted scheduler, deterministic ordering, exact numeric normalization,
|
|
||||||
selector trie, and deliberately pairwise registration-overlap validation remain
|
|
||||||
selected design choices; they are not findings here.
|
|
||||||
|
|
||||||
Evidence and validation:
|
|
||||||
|
|
||||||
- Code inspection establishes the complexity of findings 19–23 on bounded-depth
|
|
||||||
inputs with linear-sized results. Required result/history copies and genuinely
|
|
||||||
growing inheritance output are excluded from the alleged excess work. Bounds
|
|
||||||
describe internal operations, not new public performance guarantees.
|
|
||||||
- An isolated public-API probe reproduces finding 18 using an in-memory
|
|
||||||
filesystem, without external services or real credentials.
|
|
||||||
- Temporary tests supplied through a Go overlay, outside the repository, checked
|
|
||||||
all 768 combinations of append/upsert/replace over the 16 unique-ID lists drawn
|
|
||||||
from three IDs. Stable ordering, duplicate rejection, and whole-record payload
|
|
||||||
replacement agreed with an independent reference calculation.
|
|
||||||
- A structural overlay test confirmed that 4, 8, and 16 one-item contributions
|
|
||||||
retain 10, 36, and 136 reverse-index cells respectively in `indexSequenceRanges`.
|
|
||||||
These are representation counts, not timing or real-world benchmark results.
|
|
||||||
- `go test ./internal/engine -run 'Sequence|Collection|Inheritance|Override|SourceVersion|Metadata|History' -count=1`
|
|
||||||
passed with Go 1.26.10 on Linux. Passing existing tests does not cover the
|
|
||||||
incorrect selector scope or establish efficient growth.
|
|
||||||
|
|
||||||
The following are focused corrections within the selected model. This review
|
|
||||||
changes only the remediation record.
|
|
||||||
|
|
||||||
## 18. Match Application Registrations In Effective Item Coordinates — P2
|
|
||||||
|
|
||||||
Status: [ ] Open.
|
|
||||||
|
|
||||||
Owner: `composePendingItems` in
|
|
||||||
[`imports.go`](../../internal/engine/imports.go), especially its local
|
|
||||||
`composeDocumentsWithCollectionPolicy` call; the current
|
|
||||||
`TestPendingItemCompositionAppliesApplicationIdentityConstraints` in
|
|
||||||
[`item_policy_test.go`](../../internal/engine/item_policy_test.go) also needs review.
|
|
||||||
|
|
||||||
Pending item/helper documents are composed in item-local coordinates while the
|
|
||||||
unchanged global application selector policy is supplied to their composer.
|
|
||||||
A registration for root `[prompts]` therefore also constrains an unrelated
|
|
||||||
`[items, 0, prompts]` collection before placement. This contradicts complete,
|
|
||||||
effective-destination matching and makes an otherwise unused registration reject
|
|
||||||
valid configuration. Application selectors do not traverse sequence indexes.
|
|
||||||
|
|
||||||
Reproduction: main imports `item.yml` with destination `[items]` and placement
|
|
||||||
`sequence-item`. The item contains `id: outer` and
|
|
||||||
`prompts: [{text: payload}]`. Loading succeeds without a registration; adding
|
|
||||||
only `SequenceIdentityAt(Keys("prompts"), "id")` fails with `compose.invalid`
|
|
||||||
at `/items/0/prompts/0/id`, although no root `prompts` collection exists.
|
|
||||||
|
|
||||||
Required remediation: separate item-local YAML policy from application matching
|
|
||||||
in effective coordinates, including nested helper scopes. Preserve applicable
|
|
||||||
outer collection constraints without reinterpreting global selectors as local
|
|
||||||
ones. Add ordinary/layered regressions for unused root and dynamic selectors
|
|
||||||
alongside imported item-local collections. Correct tests that pin the accidental
|
|
||||||
scope; do not change selector meaning or require consumers to remove valid rules.
|
|
||||||
|
|
||||||
## 19. Avoid Dense Global-Position Storage For Sparse Item Projections — P2
|
|
||||||
|
|
||||||
Status: [ ] Open.
|
|
||||||
|
|
||||||
Owners: `newSequenceItemProjectionIndex` in
|
|
||||||
[`sequence_projection.go`](../../internal/engine/sequence_projection.go),
|
|
||||||
`indexSequenceRange` / `projectedCompositionOrigins` in
|
|
||||||
[`compose.go`](../../internal/engine/compose.go), and contribution indexing in
|
|
||||||
[`history.go`](../../internal/engine/history.go).
|
|
||||||
|
|
||||||
The reverse projection slice is sized to `maxOutput + 1`, even for one incoming
|
|
||||||
item assigned to a late assembled position. `indexSequenceRange` materializes
|
|
||||||
these indexes for ordinary contiguous append ranges too. With N one-item sources
|
|
||||||
contributing to one sequence, reverse slices retain `1 + 2 + ... + N` cells.
|
|
||||||
Initialization time and simultaneously retained auxiliary storage are therefore
|
|
||||||
quadratic despite only N actual mappings. Origin/history projection constructs
|
|
||||||
the same representation through additional callers. Finding 16's policy-copy
|
|
||||||
correction does not remove this independent cost.
|
|
||||||
|
|
||||||
Required remediation: retain arithmetic start/end lookup for contiguous ranges
|
|
||||||
and use storage proportional to actual mappings for scattered positions, such as
|
|
||||||
a sparse reverse map. Audit shared callers and avoid rebuilding equivalent indexes
|
|
||||||
inside item queries. Preserve bidirectional lookup, absent positions, upsert
|
|
||||||
replacement evidence, ordering, and detached results. Verify the representation
|
|
||||||
on many one-item sources and noncontiguous upserts; add no timing threshold or
|
|
||||||
collection-size limit.
|
|
||||||
|
|
||||||
## 20. Remove Full Metadata Scans Per CLI Replacement Leaf — P2
|
|
||||||
|
|
||||||
Status: [ ] Open.
|
|
||||||
|
|
||||||
Owners: `updateOverridePresence`, `deletePresencePathSubtree`, and
|
|
||||||
`updateOverrideOrigins` in
|
|
||||||
[`override_decode.go`](../../internal/engine/override_decode.go).
|
|
||||||
|
|
||||||
Every replaced scalar or subtree scans all presence keys, then all origin keys,
|
|
||||||
to find matching prefixes. A single partial-mapping override updating N scalar
|
|
||||||
children in an N-field configuration performs quadratic map-entry visits. This
|
|
||||||
does not require N CLI sources or growing historical output: the same effective
|
|
||||||
maps remain approximately N entries throughout the update.
|
|
||||||
|
|
||||||
Required remediation: delete affected entries by walking the already known
|
|
||||||
existing subtree, or use one batch-local prefix index where needed. Retain
|
|
||||||
necessary copies at ownership boundaries without rescanning unrelated paths for
|
|
||||||
each leaf. Preserve retained explicit zero/false values, removal of replaced
|
|
||||||
descendants, null/kind changes, origin attribution, and detached loads. Use the
|
|
||||||
existing nested CLI and ownership regressions plus focused broad-mapping coverage;
|
|
||||||
do not change override semantics or impose batch limits.
|
|
||||||
|
|
||||||
## 21. Localize Inheritance Policy Work To The Relevant Scope — P2
|
|
||||||
|
|
||||||
Status: [ ] Open.
|
|
||||||
|
|
||||||
Owners: `inheritancePolicyScopeAtPath` in
|
|
||||||
[`inheritance.go`](../../internal/engine/inheritance.go), graph preparation in
|
|
||||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go),
|
|
||||||
and policy handoff/retirement in
|
|
||||||
[`inheritance_walk.go`](../../internal/engine/inheritance_walk.go).
|
|
||||||
|
|
||||||
Scope localization scans every entry in the supplied policy table for each
|
|
||||||
definition. Mapping graph preparation also clones the full supplied table, and
|
|
||||||
the walker passes its global table to each independent scope and scans that table
|
|
||||||
again during replacement. For S scopes or siblings and P unrelated collection
|
|
||||||
policies, these paths perform repeated S-by-P work and copies.
|
|
||||||
|
|
||||||
For example, N independent mapping groups can each contain a constant-sized
|
|
||||||
`base`/`child extends base` pair and one YAML collection declaration beneath its
|
|
||||||
base. Input, expanded values, and relationship evidence remain linear in N, but
|
|
||||||
each group's graph/localization visits or clones the other groups' policies.
|
|
||||||
This is separate from necessary expansion along a genuinely growing parent chain
|
|
||||||
and from the selected dependency scheduler.
|
|
||||||
|
|
||||||
Required remediation: partition or index policies by semantic scope once and
|
|
||||||
carry the relevant state through traversal, including efficient descendant
|
|
||||||
retirement as inheritance introduces or removes destinations. Clone owned
|
|
||||||
in-scope state rather than unrelated global state. Preserve record owners,
|
|
||||||
pending declarations, mode lifetime, destination-local registration matching,
|
|
||||||
and independent templates. Verify wide independent groups and nested item scopes;
|
|
||||||
do not replace the agreed scheduling algorithm or add global caches.
|
|
||||||
|
|
||||||
## 22. Index Fallback Location Lookup During Effective Projection — P2
|
|
||||||
|
|
||||||
Status: [ ] Open.
|
|
||||||
|
|
||||||
Owner: `documentWithMetadataLocations` in
|
|
||||||
[`override_context.go`](../../internal/engine/override_context.go), using
|
|
||||||
`nearestDocumentLocation` in
|
|
||||||
[`validation.go`](../../internal/engine/validation.go).
|
|
||||||
|
|
||||||
The new effective-projection walk calls the unindexed nearest-location helper
|
|
||||||
for each field lacking a real origin location, often once for its mapping entry
|
|
||||||
and again for its value. Each call starts at the fallback root and linearly scans
|
|
||||||
mapping entries. With N authored root fields and N different default-only root
|
|
||||||
fields, each default's failed lookup scans all N authored keys: quadratic work
|
|
||||||
for linear-sized input and output. The shared lifecycle calls this path even
|
|
||||||
without CLI assignments or a resolver.
|
|
||||||
|
|
||||||
Required remediation: share an indexed fallback lookup or traverse corresponding
|
|
||||||
containers together while carrying the nearest available location. Existing
|
|
||||||
`documentLookup` / mapping-index machinery can support bounded-depth lookups
|
|
||||||
without repeated wide scans. Preserve nearest-ancestor fallback, source IDs,
|
|
||||||
synthetic locations, secret-reference diagnostics, and publication ownership.
|
|
||||||
Exercise mixed authored/default-only broad mappings; do not weaken attribution
|
|
||||||
or skip needed location attachment to avoid the work.
|
|
||||||
|
|
||||||
## 23. Index Repeated Collection-Policy Document Lookups — P2
|
|
||||||
|
|
||||||
Status: [ ] Open.
|
|
||||||
|
|
||||||
Owners: `mergeOverlayMappingsWithCollectionPolicy` in
|
|
||||||
[`overlay.go`](../../internal/engine/overlay.go) and
|
|
||||||
`mergeInheritanceCollectionPolicies` in
|
|
||||||
[`inheritance_collection.go`](../../internal/engine/inheritance_collection.go).
|
|
||||||
|
|
||||||
These routines loop over collection policies/declarations and call plain
|
|
||||||
`documentAtPath` for each destination. That helper linearly searches every
|
|
||||||
mapping on the path. N independent collections below N sibling mapping keys
|
|
||||||
therefore cause quadratic mapping-entry searches when checking which lower
|
|
||||||
policies the incoming tier touches. The inheritance parent/child lookups and
|
|
||||||
retained-value declaration checks have the same pattern. YAML declarations alone
|
|
||||||
can establish these policies; the deliberately pairwise application-registration
|
|
||||||
overlap check is not involved.
|
|
||||||
|
|
||||||
Required remediation: reuse a document lookup built once for each stable input
|
|
||||||
tree, or use the already collected claims where their scope is sufficient.
|
|
||||||
Index a mutated result only after its value-changing phase, rather than retaining
|
|
||||||
a stale lookup. Preserve absent destinations, non-sequence pending declarations,
|
|
||||||
literal key/index distinctions, retirement, and error locations. Verify broad
|
|
||||||
independent collections across precedence and inheritance; no new public cache,
|
|
||||||
shape requirement, or performance gate is needed.
|
|
||||||
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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#settled-scope-decisions).
|
||||||
|
It is intended for maintainers reviewing the remediation's scope and architecture.
|
||||||
|
|
||||||
|
## Document Ownership
|
||||||
|
|
||||||
|
- [Archived audit](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md) records 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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#b-callback-and-filesystem-lifetime)
|
||||||
|
and **C01, C02** in [filesystem traversal](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#e-collection-processing-and-efficiency)
|
||||||
|
and **F08** in [secret-failure lookup](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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.
|
||||||
@@ -1,998 +0,0 @@
|
|||||||
# Sequence Identity And Combination Roadmap
|
|
||||||
|
|
||||||
Status: settled feature roadmap; implementation is specified in the
|
|
||||||
[staged implementation plan](implementation.md). This document defines the
|
|
||||||
targeted end state, not an implemented contract. The implementation plan owns
|
|
||||||
execution order, exact Go names and signatures, bookkeeping, and validation.
|
|
||||||
|
|
||||||
## Purpose And Scope
|
|
||||||
|
|
||||||
Allow configuration authors to assemble collections from independently authored
|
|
||||||
records, declare identity in configuration, and progressively extend a baseline
|
|
||||||
collection through higher layers. Authors can replace the entire collection,
|
|
||||||
append distinct records, or replace complete records by identity while retaining
|
|
||||||
unmatched records. Sibling inheritance uses the same sequence operations and
|
|
||||||
identity-based defaults. Within an identified sequence, a record can explicitly
|
|
||||||
inherit settings from another record in that sequence while keeping its own
|
|
||||||
identity.
|
|
||||||
|
|
||||||
The motivating use case is a prompt catalog whose source-backed baseline supplies
|
|
||||||
records and whose application or installation layers add or supersede records.
|
|
||||||
Applications can optionally impose identity constraints that configuration must
|
|
||||||
respect and that hold in the final loaded configuration. These constraints can
|
|
||||||
cover exact destinations or collections beneath dynamically named mapping keys,
|
|
||||||
without requiring the application to discover those names before loading.
|
|
||||||
|
|
||||||
The feature covers ordinary and layered loading, imported items, inline
|
|
||||||
sequences, selected overlays, and sibling inheritance. Typed defaults, CLI
|
|
||||||
assignments, and application resolution retain their own combination semantics;
|
|
||||||
CLI application moves later in the loading lifecycle.
|
|
||||||
|
|
||||||
Automatic recursive merging of matching records remains deferred
|
|
||||||
(see [future work](future.md#1-recursive-body-merging-for-identified-records));
|
|
||||||
explicit inheritance between distinct records is included. The initial
|
|
||||||
declaration surface is `configkit.sequences`; `key` and `mode` shorthand on
|
|
||||||
imports is deferred. Application lists remain
|
|
||||||
ordinary YAML sequences without control wrappers. Unkeyed sequence concatenation
|
|
||||||
is outside this feature.
|
|
||||||
|
|
||||||
## Current Baseline And Required Changes
|
|
||||||
|
|
||||||
The [import contract](../config.md#explicit-imports) owns existing selectors,
|
|
||||||
destinations, placement, and ordering. `placement: sequence-item` already places
|
|
||||||
each selected source mapping as one item at its effective destination. Item-local
|
|
||||||
helper imports can contribute fields before placement.
|
|
||||||
|
|
||||||
The [composition contract](../config.md#strict-document-composition),
|
|
||||||
[layered-loading contract](../config.md#layered-loading), and
|
|
||||||
[inheritance contract](../config.md#sibling-inheritance) describe the implemented
|
|
||||||
baseline:
|
|
||||||
|
|
||||||
| Area | Current behavior | Target behavior |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Identity declaration | `Definition.SequenceCompositions` registers one literal string field at each registered mapping path. | YAML collection declarations and optional authoritative application registrations support field sets and scalar identity components. |
|
|
||||||
| Application registration destinations | Registrations select exact literal mapping paths. | Exact and structural selectors can impose identity beneath arbitrary mapping keys, independently at each matched destination. |
|
|
||||||
| Strict composition | Registered sequences append distinct string identities and reject duplicates; repeated unregistered sequences conflict. | Collections with effective identity append distinct records and reject duplicates. Applicable lower-tier identity is available before composing higher-tier peers. Unidentified sequences retain strict-conflict behavior. |
|
|
||||||
| Source precedence and selected overlays | A present higher sequence replaces the lower sequence wholesale. | Collection policy selects `replace`, `append`, or `upsert`; overlays can declare policy for inline sequences. |
|
|
||||||
| Sibling inheritance | An inheriting mapping recursively overlays its resolved parent; supplied sequences replace wholesale. | Mappings retain recursive combination, while sequences use destination policy and the same three operations. |
|
|
||||||
| Sequence-member inheritance | A sequence element has no sibling scope for its own inheritance relationship. | An identified sequence provides a sibling scope: a record can inherit from one record in the same sequence, selected by complete identity. |
|
|
||||||
| CLI overrides | Apply before inheritance and typed defaults/decoding. | Apply after inheritance and typed defaults/decoding, before application resolution. |
|
|
||||||
| Identity enforcement | Registration governs strict sequence composition. | Structural identity checks cover identified combinations; application registrations additionally constrain final effective sequences. |
|
|
||||||
|
|
||||||
Source-backed underlays participate in source assembly. `Definition.Defaults`
|
|
||||||
provides typed defaults later; extending source combination does not turn typed
|
|
||||||
default lists into source underlays.
|
|
||||||
|
|
||||||
## Collection Declarations
|
|
||||||
|
|
||||||
Use a source-root `configkit.sequences` block containing collection declarations.
|
|
||||||
Each entry identifies a `destination` and may declare `key`, `mode`, or both.
|
|
||||||
Identity and mode can be supplied independently. This form serves imported items,
|
|
||||||
inline records, ordinary mapping imports, and inline sequences in selected
|
|
||||||
overlays.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [prompts]
|
|
||||||
key: [namespace, id]
|
|
||||||
mode: append
|
|
||||||
imports:
|
|
||||||
- dir: prompts
|
|
||||||
pattern: '*.yaml'
|
|
||||||
destination: [prompts]
|
|
||||||
placement: sequence-item
|
|
||||||
```
|
|
||||||
|
|
||||||
Each selected file contributes a record such as:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
namespace: support
|
|
||||||
id: welcome
|
|
||||||
text: Welcome
|
|
||||||
```
|
|
||||||
|
|
||||||
Here append retains a lower-layer collection and adds distinct higher-layer
|
|
||||||
records. Identity and combination controls belong to the collection declaration,
|
|
||||||
so imported and inline contributions share one policy surface.
|
|
||||||
|
|
||||||
### Destinations And Scope
|
|
||||||
|
|
||||||
`destination` uses the existing sequence of literal mapping keys. Resolve it
|
|
||||||
relative to the declaring source's placement: a fragment mounted under `catalog`
|
|
||||||
can declare `[prompts]` locally. A sequence-item source can declare a nested
|
|
||||||
collection within its own item without referring to an eventual numeric index.
|
|
||||||
Preserve source and item-local scope boundaries.
|
|
||||||
|
|
||||||
A collection's scope is its resolved destination within its composition scope.
|
|
||||||
Resolve relative destinations before reconciling declarations. Different imported
|
|
||||||
items retain separate scopes for their nested collections. A declaration governs
|
|
||||||
all contributions to its collection in the tier, including imported and inline
|
|
||||||
records; it does not govern only records in the declaring file.
|
|
||||||
|
|
||||||
Gather declarations before composing the relevant scope so their effect does not
|
|
||||||
depend on which contribution is assembled first. Retain applicable controls
|
|
||||||
through source precedence and inheritance, then keep them out of application
|
|
||||||
decoding.
|
|
||||||
|
|
||||||
Sequence declarations are independent of filesystem import enablement. Selected
|
|
||||||
overlays may contain `configkit.sequences`, extending the current blanket
|
|
||||||
prohibition on overlay `configkit` declarations. This does not enable imports
|
|
||||||
in overlays.
|
|
||||||
|
|
||||||
### Overlay Authoring
|
|
||||||
|
|
||||||
For an existing sequence `[foo, bar]`, an overlay can replace `foo` in full:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [prompts]
|
|
||||||
key: id
|
|
||||||
mode: upsert
|
|
||||||
prompts:
|
|
||||||
- id: foo
|
|
||||||
text: Revised definition
|
|
||||||
```
|
|
||||||
|
|
||||||
The result is `[foo*, bar]`. The explicit upsert mode can be omitted when no
|
|
||||||
different explicit mode applies from a lower tier: effective identity then
|
|
||||||
selects upsert. To add a distinct record instead:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [prompts]
|
|
||||||
key: id
|
|
||||||
mode: append
|
|
||||||
prompts:
|
|
||||||
- id: baz
|
|
||||||
text: Another prompt
|
|
||||||
```
|
|
||||||
|
|
||||||
The result is `[foo, bar, baz]`. To replace the whole sequence:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [prompts]
|
|
||||||
mode: replace
|
|
||||||
prompts:
|
|
||||||
- id: bar
|
|
||||||
text: Bar definition
|
|
||||||
- id: baz
|
|
||||||
text: Baz definition
|
|
||||||
```
|
|
||||||
|
|
||||||
The result is `[bar, baz]`. Explicitly selecting the default operation is valid.
|
|
||||||
With no applicable explicit mode, omitting this declaration selects replace for
|
|
||||||
an unidentified collection and upsert for an identified one. A retained explicit
|
|
||||||
mode continues to govern.
|
|
||||||
|
|
||||||
## Identity Definitions And Constraints
|
|
||||||
|
|
||||||
### Field Sets
|
|
||||||
|
|
||||||
`key` accepts a string or a nonempty list of strings naming direct, literal
|
|
||||||
fields in each record. A name such as `metadata.id` denotes that literal field,
|
|
||||||
not a nested lookup. Treat field names as a set: remove repeated names and
|
|
||||||
ignore authored order when comparing definitions.
|
|
||||||
|
|
||||||
Thus `id`, `[id]`, and `[id, id]` are equivalent; `[namespace, id]`,
|
|
||||||
`[id, namespace]`, and `[namespace, id, id]` are also equivalent. An empty list
|
|
||||||
is invalid because it supplies no identity fields. These rules apply to both
|
|
||||||
configuration declarations and application registrations.
|
|
||||||
|
|
||||||
Use a consistent field order internally to construct identity tuples. Preserve
|
|
||||||
component types and tuple boundaries so unstructured concatenation cannot cause
|
|
||||||
otherwise distinct identities to collide.
|
|
||||||
|
|
||||||
### Scalar Components And Equality
|
|
||||||
|
|
||||||
Each identified record must be a mapping with every designated identity field
|
|
||||||
present. Components may be strings, numbers, booleans, or explicit null. Missing
|
|
||||||
fields and mapping or sequence components are invalid. Present null differs
|
|
||||||
from absence; two single-field null identities are equal. Compound identities
|
|
||||||
are equal when all corresponding components are equal.
|
|
||||||
|
|
||||||
Strings compare exactly, including empty strings, without trimming, case folding,
|
|
||||||
or normalization. Scalar categories remain distinct: `"1"`, `1`, `true`, and
|
|
||||||
null are different identities.
|
|
||||||
|
|
||||||
Numbers compare by mathematical value across integer, fractional, and exponent
|
|
||||||
spellings: `1`, `1.0`, and `1e0` are equal, as are `0` and `-0`. Use exact numeric
|
|
||||||
normalization for identity comparison without rounding through a floating-point
|
|
||||||
conversion. This does not rewrite authored values or change existing typed
|
|
||||||
decoding and projection semantics. The parser's rejection of non-finite numeric
|
|
||||||
forms remains unchanged.
|
|
||||||
|
|
||||||
### Application-Owned Constraints
|
|
||||||
|
|
||||||
Retain an optional application interface for registering collections and imposing
|
|
||||||
their identity definitions. Configuration may define identity where no application
|
|
||||||
constraint exists. When a declaration omits `key`, an applicable registration
|
|
||||||
supplies identity. An explicit equivalent definition is accepted, and a different
|
|
||||||
definition fails under every mode, including replace.
|
|
||||||
|
|
||||||
For example, if the application requires `id`, `key: [id, name]` is invalid:
|
|
||||||
that compound identity would allow repeated `id` values with different names.
|
|
||||||
A registration is an authoritative constraint, not an omission-filling default.
|
|
||||||
Copying a collection through inheritance does not create another application
|
|
||||||
registration; registrations constrain their registered destinations.
|
|
||||||
|
|
||||||
Identity constraints validate actual sequences. They do not independently require
|
|
||||||
a path to exist, be non-null, or have sequence shape. The application schema and
|
|
||||||
validation own those requirements. Final-result enforcement for application
|
|
||||||
registrations is defined under [Validation And Diagnostics](#validation-and-diagnostics).
|
|
||||||
|
|
||||||
### Application Registration API
|
|
||||||
|
|
||||||
Use one public sequence-identity registration type, collected in one field on
|
|
||||||
`Definition`. Each registration contains an explicit destination selector and
|
|
||||||
its identity fields. Exact and dynamic registrations share matching, overlap
|
|
||||||
validation, identity comparison, and lifecycle behavior.
|
|
||||||
|
|
||||||
Provide two constructors returning that same registration type:
|
|
||||||
|
|
||||||
- An exact-path convenience constructor accepting a literal `Path` and identity
|
|
||||||
field names. It converts the path into a selector containing literal segments.
|
|
||||||
- An explicit-selector constructor accepting a structural selector and identity
|
|
||||||
field names, for destinations that include arbitrary mapping keys.
|
|
||||||
|
|
||||||
Both constructors accept identity fields as variadic strings, so one-field and
|
|
||||||
compound identities use the same construction form. Apply the settled field-set
|
|
||||||
rules, including deduplication. Invalid registrations, including an empty identity
|
|
||||||
field set, fail during application-definition validation.
|
|
||||||
|
|
||||||
Existing `Path` values retain their literal meaning throughout ConfigKit. The
|
|
||||||
exact-path constructor supplies convenience at the registration boundary; dynamic
|
|
||||||
matching uses the explicit selector type. Both forms can appear together in the
|
|
||||||
same definition field.
|
|
||||||
|
|
||||||
Replace the current registration shape with this model. Preserving the existing
|
|
||||||
`SequenceComposition` fields and `Definition.SequenceCompositions` spelling is not
|
|
||||||
a requirement, and no compatibility facade is needed solely to retain that syntax.
|
|
||||||
Choose names centered on sequence identity, reflecting its role in composition,
|
|
||||||
inheritance, and final validation. Exact Go type, field, and constructor names
|
|
||||||
and signatures belong in the implementation plan.
|
|
||||||
|
|
||||||
### Dynamic Application Registration Destinations
|
|
||||||
|
|
||||||
Allow applications to register identity for collections whose containing mapping
|
|
||||||
keys become known only during loading. For example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
collections:
|
|
||||||
weatherreporter:
|
|
||||||
prompts:
|
|
||||||
- id: foo.bar
|
|
||||||
text: Report the weather.
|
|
||||||
homewatch:
|
|
||||||
prompts:
|
|
||||||
- id: foo.bar
|
|
||||||
text: Report the household status.
|
|
||||||
```
|
|
||||||
|
|
||||||
One application registration can require `id` for every namespace's `prompts`
|
|
||||||
list. The equal IDs above are valid because each list has its own uniqueness
|
|
||||||
scope. An optional root `prompts` list can have a separate exact registration.
|
|
||||||
|
|
||||||
Use an explicit application-side structural selector composed of literal mapping
|
|
||||||
keys and an arbitrary-mapping-key segment. Conceptually, the namespace rule is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Literal("collections") -> AnyMappingKey -> Literal("prompts")
|
|
||||||
```
|
|
||||||
|
|
||||||
This notation explains the selector; it is not a Go signature or YAML syntax.
|
|
||||||
Each segment consumes exactly one mapping level. Match the complete destination,
|
|
||||||
not an arbitrary prefix or suffix. Literal keys remain exact, including empty
|
|
||||||
strings and keys containing punctuation such as `*`, `/`, or `.`. An arbitrary-key
|
|
||||||
segment is distinct from any literal spelling and does not match a sequence
|
|
||||||
index. Existing `Path` values retain their literal meaning.
|
|
||||||
|
|
||||||
This capability belongs to the reusable application definition and applies to
|
|
||||||
ordinary and layered loads. YAML collection destinations and import destinations
|
|
||||||
retain their existing literal syntax. Recursive search, regular expressions,
|
|
||||||
filesystem glob rules, sequence-index selectors, and custom matching callbacks
|
|
||||||
are outside this addition.
|
|
||||||
|
|
||||||
At each concrete matched destination, apply the same authoritative identity
|
|
||||||
constraint as an exact registration. Use the settled field-set and scalar-equality
|
|
||||||
rules. Do not pool identities across destinations or require records to repeat
|
|
||||||
the containing mapping key as an identity component. Applications retain ownership
|
|
||||||
of namespace interpretation, qualified runtime IDs, and domain validation.
|
|
||||||
|
|
||||||
Matching supplies effective identity before relevant strict composition and
|
|
||||||
precedence operations. Without an applicable explicit mode, this identity selects
|
|
||||||
upsert under the existing default rule. It also supplies identity for inheritance
|
|
||||||
within the matched sequence. YAML declarations must agree with it under every
|
|
||||||
mode, including replace. These are ordinary application constraints with a broader
|
|
||||||
destination selector, not a separate final-validation facility.
|
|
||||||
|
|
||||||
### Registration Overlap
|
|
||||||
|
|
||||||
Equivalent application constraints coalesce at any shared destination. Equivalence
|
|
||||||
uses the identity field-set rules: for example, `id` and `[id, id]` agree.
|
|
||||||
Incompatible constraints fail; exact registrations do not override broader
|
|
||||||
selectors, and registration order grants no precedence.
|
|
||||||
|
|
||||||
Detect incompatible overlapping registrations while validating the application
|
|
||||||
definition, before source acquisition. For these fixed-depth mapping selectors,
|
|
||||||
overlap exists when the selectors have the same number of segments and every
|
|
||||||
corresponding pair can match a common key. Unequal literal keys at any position
|
|
||||||
make the selectors disjoint; an arbitrary-key segment can overlap a literal or
|
|
||||||
another arbitrary-key segment. Compare segment structure rather than rendered
|
|
||||||
path strings. No configuration pre-read is needed.
|
|
||||||
|
|
||||||
Thus an `id` rule for all namespace prompt lists conflicts with a `[id, name]`
|
|
||||||
rule for one named namespace, even if that namespace is absent from a particular
|
|
||||||
load. This is an invalid application definition. A valid selector matching no
|
|
||||||
collections is harmless: registration does not require a path to exist, create
|
|
||||||
collections, or impose shape and nullability requirements.
|
|
||||||
|
|
||||||
### Matching Across The Loading Lifecycle
|
|
||||||
|
|
||||||
Match application selectors against effective configuration destinations after
|
|
||||||
accounting for input placement. Physical filenames and source-local paths do not
|
|
||||||
determine applicability. Make constraints available before composing contributions
|
|
||||||
at those destinations, including namespaces first introduced by imports, mounted
|
|
||||||
inputs, or higher tiers. Apply matching constraints to key-only YAML declarations
|
|
||||||
as well, even before their sequence exists. Consumers need neither a preliminary
|
|
||||||
load nor source inspection to enumerate namespaces.
|
|
||||||
|
|
||||||
During inheritance, apply registrations at the inheriting destination, including
|
|
||||||
newly created matching destinations. Copying a collection does not transfer its
|
|
||||||
application registrations to a different path. The existing YAML-policy ownership
|
|
||||||
and transfer rules remain separate from application selector matching.
|
|
||||||
|
|
||||||
During final validation, find matching actual sequences in the final effective
|
|
||||||
configuration. This includes namespaces introduced or replaced by typed defaults,
|
|
||||||
CLI overrides, and application resolution. Reusing only destinations found during
|
|
||||||
source assembly would miss these values. Use the same selector meaning and
|
|
||||||
identity validator in both phases; later assignments do not trigger composition
|
|
||||||
or inheritance again. YAML-only identities retain their existing enforcement
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
## Strict Composition And Precedence Operations
|
|
||||||
|
|
||||||
### Compose Peers Before Applying Precedence
|
|
||||||
|
|
||||||
Resolve applicable identity and mode policy, strictly compose the tier's peer
|
|
||||||
contributions, then apply one selected operation per collection against the
|
|
||||||
accumulated lower value. The tier operation does not sequentially replace peer
|
|
||||||
source contributions. Selected overlay contributions use the same combination
|
|
||||||
semantics.
|
|
||||||
|
|
||||||
Within a strict tier, equivalent identity definitions coalesce and different
|
|
||||||
field sets fail. Agreeing explicit modes coalesce; conflicting explicit modes
|
|
||||||
fail. Omission expresses no preference. A root declaration or a later declaration
|
|
||||||
has no precedence over conflicting peers. Explicitly selecting a default mode
|
|
||||||
is still a real instruction and can conflict with another explicit mode.
|
|
||||||
|
|
||||||
Identified peer sequences append distinct records. Duplicate identities among
|
|
||||||
peers or within a contributed list fail under all three modes, including replace
|
|
||||||
and upsert. Equal or disjoint record payloads do not excuse a duplicate identity.
|
|
||||||
Unidentified sequences retain their current strict-composition conflict behavior.
|
|
||||||
|
|
||||||
For example, higher-tier fragments `[C]` and `[D]` with an agreed identity assemble
|
|
||||||
as `[C, D]`. Against lower `[A, B]`, append or upsert produces `[A, B, C, D]`,
|
|
||||||
while replace produces `[C, D]`. Peers `[B]` and `[B*]` conflict even under
|
|
||||||
upsert because neither has higher precedence.
|
|
||||||
|
|
||||||
### Available Operations
|
|
||||||
|
|
||||||
Use one spelling for each mode: `replace`, `append`, and `upsert`.
|
|
||||||
|
|
||||||
| 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, retain unmatched lower records, and append new identities. |
|
|
||||||
|
|
||||||
Append and upsert require effective identity. ConfigKit does not infer identity
|
|
||||||
from an `id` field or record position. Whole-sequence replacement does not require
|
|
||||||
identity agreement with lower YAML policy, but application constraints still apply.
|
|
||||||
|
|
||||||
Letters denote record identities; `B*` has the same identity as `B` with a different
|
|
||||||
payload:
|
|
||||||
|
|
||||||
| Lower | Higher | Mode | Result |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `[A, B]` | `[C]` | `append` | `[A, B, C]` |
|
|
||||||
| `[A, B]` | `[C]` | `replace` | `[C]` |
|
|
||||||
| `[A, B]` | `[B*]` | `append` | Duplicate-identity error |
|
|
||||||
| `[A, B]` | `[B*]` | `replace` | `[B*]` |
|
|
||||||
| `[A, B]` | `[B*]` | `upsert` | `[A, B*]` |
|
|
||||||
| `[A, B]` | `[B*, C]` | `upsert` | `[A, B*, C]` |
|
|
||||||
|
|
||||||
Upsert replaces the whole matched record. If lower `B` has `id`, `text`, and
|
|
||||||
`temperature`, while higher `B` has only `id` and `text`, the result at this
|
|
||||||
boundary has only `id` and `text`. Subsequent defaulting or other lifecycle
|
|
||||||
operations retain their own semantics. Mappings continue to combine recursively;
|
|
||||||
matching sequence records do not recursively merge fields.
|
|
||||||
|
|
||||||
### Mode Selection And Persistence
|
|
||||||
|
|
||||||
Select the operation in this order:
|
|
||||||
|
|
||||||
1. Reconcile explicit modes within the current tier; conflicting peers fail.
|
|
||||||
2. Use the current tier's explicit mode if supplied.
|
|
||||||
3. Otherwise retain an applicable explicit mode from a lower tier.
|
|
||||||
4. With no explicit mode, infer upsert when effective identity is available.
|
|
||||||
5. Otherwise infer replace.
|
|
||||||
|
|
||||||
Effective identity can come from an application registration, current declarations,
|
|
||||||
or applicable lower policy, subject to the identity agreement and lifetime rules
|
|
||||||
below. Determine the inferred mode from available identity before applying the
|
|
||||||
operation. A plain higher-tier sequence can therefore use upsert without repeating
|
|
||||||
a key declaration.
|
|
||||||
|
|
||||||
An explicit mode persists at its applicable destination until a higher-priority
|
|
||||||
explicit declaration changes it. New sequence values alone do not reset it, and
|
|
||||||
an unrelated overlay leaves it intact. A mode-only declaration can change policy
|
|
||||||
without supplying records; it neither clears the collection nor retroactively
|
|
||||||
recombines earlier tiers. Later source contributions and inheritance use the
|
|
||||||
resulting policy.
|
|
||||||
|
|
||||||
| Lower explicit mode | Higher declaration | Effective mode |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `append` | Mode omitted | `append` |
|
|
||||||
| `append` | `upsert` | `upsert` |
|
|
||||||
| `upsert` | `replace` | `replace` |
|
|
||||||
| None | Mode omitted, effective identity available | Inferred `upsert` |
|
|
||||||
| None | Mode omitted, no effective identity | Inferred `replace` |
|
|
||||||
|
|
||||||
Only explicit modes persist. Inferred defaults are recomputed from effective
|
|
||||||
identity when needed. Explicit replace continues to apply in subsequent tiers
|
|
||||||
until another mode is declared; introducing identity alone does not override it.
|
|
||||||
Mode persistence does not preserve YAML identity discarded by replacement, nor
|
|
||||||
policies owned by discarded records. Source precedence and inheritance use the
|
|
||||||
same destination policy, without separate per-phase settings. CLI assignments
|
|
||||||
remain outside mode selection.
|
|
||||||
|
|
||||||
### Presence, Empty Sequences, Null, And Kind Mismatches
|
|
||||||
|
|
||||||
At a collection's precedence boundary:
|
|
||||||
|
|
||||||
| 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 | Ordinary replacement, subject to application schema. | Error: the operation requires a sequence. |
|
|
||||||
|
|
||||||
A declaration alone does not clear a collection. An optional import selecting
|
|
||||||
nothing contributes nothing and does not erase existing records. An absent lower
|
|
||||||
collection can be initialized by an incoming sequence under any mode, subject to
|
|
||||||
identity requirements. Explicit lower null or another non-sequence value is not
|
|
||||||
absence: append and upsert fail against it, while replacement remains available.
|
|
||||||
An empty incoming sequence bypasses neither kind checks nor identity requirements.
|
|
||||||
|
|
||||||
With effective identity and no explicit mode, `prompts: []` uses upsert and does
|
|
||||||
not clear the collection. Clearing requires replace, selected in the current tier
|
|
||||||
or retained from lower policy. Replacing an identified collection with null also
|
|
||||||
requires replace and a schema permitting null. Collection null is distinct from
|
|
||||||
a present null identity component inside a record.
|
|
||||||
|
|
||||||
### Ordering
|
|
||||||
|
|
||||||
Retain existing dependency-ready source scheduling within each tier and authored
|
|
||||||
item order within lists. Gathering declarations does not change assembly order;
|
|
||||||
identity values do not sort records.
|
|
||||||
|
|
||||||
- Append keeps lower records in order, followed by higher records in assembled order.
|
|
||||||
- Replace keeps only higher records in their assembled order.
|
|
||||||
- Upsert keeps a matched record's lower position and appends new identities in
|
|
||||||
higher-tier order.
|
|
||||||
|
|
||||||
For example, lower `[A, B, C]` and higher `[C*, A*, D]` produce `[A*, B, C*, D]`
|
|
||||||
under upsert. Whole-sequence replacement gives the higher tier control of the
|
|
||||||
complete sequence order.
|
|
||||||
|
|
||||||
## Policy Agreement, Lifetime, And Ownership
|
|
||||||
|
|
||||||
### Identity Agreement And Replacement
|
|
||||||
|
|
||||||
Append and upsert use one agreed identity for all participating records:
|
|
||||||
|
|
||||||
| Lower identity | Higher declaration | Result when combining records |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `id` | Omitted | Use `id`. |
|
|
||||||
| `id` | `id`, `[id]`, or `[id, id]` | Accept the equivalent definition. |
|
|
||||||
| `id` | `[namespace, id]` | Error: different definitions. |
|
|
||||||
| None | `id` | Establish `id` and validate all participating records. |
|
|
||||||
|
|
||||||
Introducing identity for an unkeyed lower sequence requires checking both lower
|
|
||||||
and incoming records, including uniqueness within each contribution. With no
|
|
||||||
identity from the application or either participating collection, append and
|
|
||||||
upsert fail.
|
|
||||||
|
|
||||||
YAML identity follows the collection that survives an operation:
|
|
||||||
|
|
||||||
| Operation | Effective YAML identity |
|
|
||||||
| --- | --- |
|
|
||||||
| Append or upsert | Retain or establish the agreed identity. |
|
|
||||||
| Whole-sequence replacement | Discard lower YAML identity and use the replacing tier's identity declaration, if any. |
|
|
||||||
| Replacement by null or another non-sequence value | Discard lower YAML identity and retain any identity explicitly declared by the replacing tier. |
|
|
||||||
|
|
||||||
Application constraints remain authoritative under every operation. Without an
|
|
||||||
application constraint, replacement can introduce a different identity, such as
|
|
||||||
changing from `id` to `name`. Replacing a sequence without a new key declaration
|
|
||||||
or application registration produces an unkeyed sequence. No identity-removal
|
|
||||||
directive is needed.
|
|
||||||
|
|
||||||
The reset occurs only when the collection value is actually replaced. Declaring
|
|
||||||
replace without supplying a replacement does not authorize changing the identity
|
|
||||||
of retained records. Explicit mode lifetime is separate from YAML identity
|
|
||||||
lifetime.
|
|
||||||
|
|
||||||
### Ancestor Replacement
|
|
||||||
|
|
||||||
Replacing an enclosing mapping can remove a collection without replacing that
|
|
||||||
collection directly. Apply the same identity and ownership rules to this removal:
|
|
||||||
discard the removed collection's lower YAML identity, and discard policies owned
|
|
||||||
by any removed records. Reintroducing the same path later does not restore those
|
|
||||||
discarded policies.
|
|
||||||
|
|
||||||
Independently declared explicit modes retain their existing persistence rules.
|
|
||||||
Removing a descendant collection does not remove a mode whose declaring scope
|
|
||||||
survives. Application registrations remain applicable at matching destinations,
|
|
||||||
including destinations reintroduced later. Identity newly declared by the
|
|
||||||
replacing tier follows the existing declaration and ownership rules.
|
|
||||||
|
|
||||||
For example, a root-level declaration establishes `key: id` and `mode: append`
|
|
||||||
for `[catalog, prompts]`. A higher tier replaces `catalog` with null, removing
|
|
||||||
the prompts collection and its YAML identity. The independently declared append
|
|
||||||
mode survives. If a later tier recreates `catalog.prompts`, append requires
|
|
||||||
identity supplied by a new declaration or an application registration. Without
|
|
||||||
that identity, loading fails; the later tier can instead explicitly choose
|
|
||||||
replace. The replacement of `catalog` itself follows ordinary ancestor-value
|
|
||||||
precedence and does not attempt to append null to the removed prompts list.
|
|
||||||
|
|
||||||
### Declarations Without Sequence Values
|
|
||||||
|
|
||||||
A key declaration without accompanying records applies immediately to an existing
|
|
||||||
retained sequence: check agreement and validate its records, without recombining
|
|
||||||
earlier tiers. For example, a higher tier that only declares
|
|
||||||
`destination: [prompts], key: id` identifies and validates the retained lower
|
|
||||||
`prompts` sequence.
|
|
||||||
|
|
||||||
If no collection exists, retain the declaration in its owning scope for later
|
|
||||||
contributions. An otherwise empty underlay can establish identity for main-tier
|
|
||||||
fragments. An explicit identity declaration can also remain pending at a
|
|
||||||
non-sequence destination; it does not independently impose a shape requirement.
|
|
||||||
The presence and kind rules still determine whether an attempted operation fails.
|
|
||||||
Item-local declarations retain their ownership boundaries.
|
|
||||||
|
|
||||||
### Lower-Tier Identity During Strict Composition
|
|
||||||
|
|
||||||
Resolve applicable identity before composing a higher tier's peers, using
|
|
||||||
application registrations, available lower-tier policy, and declarations gathered
|
|
||||||
within the higher tier's scope. Authors need not repeat a baseline key solely
|
|
||||||
because the higher tier has multiple fragments.
|
|
||||||
|
|
||||||
For example, an underlay establishes `key: id` for `prompts`, and two main-tier
|
|
||||||
fragments contribute `[foo]` and `[bar]` without a key declaration. They strictly
|
|
||||||
compose as `[foo, bar]` before the main tier combines with the accumulated lower
|
|
||||||
collection. If both contribute `foo`, composition fails; upsert does not give
|
|
||||||
precedence to peers.
|
|
||||||
|
|
||||||
Lower YAML identity remains subject to agreement, replacement, and ownership.
|
|
||||||
A replacing collection must not be constrained by obsolete lower identity just
|
|
||||||
because policy resolution precedes composition. Likewise, a replacement record
|
|
||||||
cannot borrow nested identity owned by the discarded lower record, even when
|
|
||||||
outer identity or numeric position matches. Application constraints still apply.
|
|
||||||
Higher-tier declarations do not retroactively repair invalid lower-tier composition.
|
|
||||||
|
|
||||||
### Nested Record Ownership
|
|
||||||
|
|
||||||
Attach item-local policies to their owning contributions so they follow surviving
|
|
||||||
records independently of numeric indexes. Whole-record upsert discards the old
|
|
||||||
record's local policies along with its fields. Whole-sequence replacement discards
|
|
||||||
policies owned by removed records. A replacement record supplies its own local
|
|
||||||
declarations; equal outer identity or final index does not transfer discarded
|
|
||||||
policies.
|
|
||||||
|
|
||||||
For example, a source imported as one record into `prompts` can declare:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [steps]
|
|
||||||
key: id
|
|
||||||
mode: append
|
|
||||||
id: foo
|
|
||||||
steps:
|
|
||||||
- id: prepare
|
|
||||||
```
|
|
||||||
|
|
||||||
The declaration governs `steps` inside that `foo` record. Retaining `foo` retains
|
|
||||||
its local policy, including the explicit mode. Upserting a new `foo` discards the
|
|
||||||
old `steps` and its declaration even though the outer record keeps its position.
|
|
||||||
|
|
||||||
Policies independently applicable at a destination are not discarded merely
|
|
||||||
because an individual record is replaced. Ownership determines which declarations
|
|
||||||
are removed. Application registrations remain constraints at their registered
|
|
||||||
destinations. Inheritance copies transfer identity and choose operations as
|
|
||||||
specified next.
|
|
||||||
|
|
||||||
## Sibling Inheritance
|
|
||||||
|
|
||||||
Complete source-tier assembly before resolving inheritance. Retain existing
|
|
||||||
mapping-sibling scopes and add identified-sequence scopes as described below.
|
|
||||||
Both use the existing dependency-ready algorithm. At each inheritance combination,
|
|
||||||
the resolved parent's collection is the lower value and the inheriting mapping's
|
|
||||||
local collection is the higher value. Mappings combine recursively; upsert within
|
|
||||||
an identified sequence remains whole-record replacement. Explicit record
|
|
||||||
inheritance is a separate construction step after those replacements.
|
|
||||||
|
|
||||||
Use the inheriting destination collection's policy: an applicable explicit mode,
|
|
||||||
including one retained through source tiers, wins. Otherwise infer upsert with
|
|
||||||
effective identity and replace without it. There is no separate default-append
|
|
||||||
rule for inheritance. The same destination policy governs source-precedence and
|
|
||||||
inheritance combinations.
|
|
||||||
|
|
||||||
YAML identity follows a collection copied through inheritance, subject to agreement
|
|
||||||
and replacement rules. A source collection's mode does not automatically become an
|
|
||||||
instruction at a different inheriting destination. For example, source `steps`
|
|
||||||
has `key: id` and `mode: append`; an inheriting mapping supplies local `steps`
|
|
||||||
without an applicable explicit mode. The copied identity selects upsert there.
|
|
||||||
An explicit destination append instead selects append. A matching destination key
|
|
||||||
agrees; a different key conflicts when combining records, while explicit replace
|
|
||||||
can establish new YAML identity subject to application constraints.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [parent, items]
|
|
||||||
key: id
|
|
||||||
child:
|
|
||||||
items:
|
|
||||||
- id: foo
|
|
||||||
value: A
|
|
||||||
- id: bar
|
|
||||||
value: B
|
|
||||||
parent:
|
|
||||||
extends: child
|
|
||||||
items:
|
|
||||||
- id: baz
|
|
||||||
value: C
|
|
||||||
```
|
|
||||||
|
|
||||||
Despite the names, `parent` inherits from `child`. Identity at `[parent, items]`
|
|
||||||
selects upsert, producing `[foo, bar, baz]`. If the local sequence instead supplies
|
|
||||||
a revised `foo`, the result is `[foo*, bar]`. Append rejects that repeated identity;
|
|
||||||
replace retains only the local sequence.
|
|
||||||
|
|
||||||
The same ordering, presence, and kind rules apply at inheritance boundaries.
|
|
||||||
An omitted local sequence retains the inherited sequence. Explicit `[]` clears it
|
|
||||||
under replace and contributes no records under append or upsert. Retain controls
|
|
||||||
until inheritance uses them, then exclude them from application decoding.
|
|
||||||
|
|
||||||
### Inheritance Between Identified Sequence Records
|
|
||||||
|
|
||||||
An identified sequence supplies a sibling scope for its records. Identity can
|
|
||||||
come from an application registration or an applicable YAML declaration. A record
|
|
||||||
may inherit from one other record in that same sequence using an `extends`
|
|
||||||
mapping that selects the parent's complete identity:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
configkit:
|
|
||||||
sequences:
|
|
||||||
- destination: [prompts]
|
|
||||||
key: id
|
|
||||||
prompts:
|
|
||||||
- id: base
|
|
||||||
model: standard
|
|
||||||
temperature: 0.5
|
|
||||||
- id: concise
|
|
||||||
extends: {id: base}
|
|
||||||
temperature: 0.2
|
|
||||||
```
|
|
||||||
|
|
||||||
The final `concise` record is:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
id: concise
|
|
||||||
model: standard
|
|
||||||
temperature: 0.2
|
|
||||||
```
|
|
||||||
|
|
||||||
The `base` record remains in the collection. Parents are ordinary records subject
|
|
||||||
to the same decoding and validation as other records; there is no abstract
|
|
||||||
template category.
|
|
||||||
|
|
||||||
Use mapping-form references for both single and compound keys. With
|
|
||||||
`key: [namespace, id]`, a reference can be
|
|
||||||
`extends: {namespace: shared, id: base}`. Match components using the collection's
|
|
||||||
field-set and scalar-equality rules, including mathematical numeric equality.
|
|
||||||
`extends: {id: null}` selects a null-valued identity; `extends: null` cancels a
|
|
||||||
relationship.
|
|
||||||
|
|
||||||
Parent-reference mappings use ordinary strict mapping composition. Item-local
|
|
||||||
helper imports may supply disjoint fields of a compound reference; overlapping
|
|
||||||
fields conflict, even when equal. For example, a helper's
|
|
||||||
`extends: {namespace: shared}` and the importing record's `extends: {id: base}`
|
|
||||||
compose into `extends: {namespace: shared, id: base}`. This assembles one record;
|
|
||||||
it does not permit strict peers to contribute duplicate record identities.
|
|
||||||
|
|
||||||
Before parent lookup, require the surviving effective reference mapping to contain
|
|
||||||
exactly the collection's identity fields, with supported scalar values. Missing
|
|
||||||
or extra fields fail. Validate the completed reference during inheritance, rather
|
|
||||||
than requiring each helper contribution to be independently complete. Superseded
|
|
||||||
references remain historical evidence; earlier strict-composition conflicts
|
|
||||||
remain errors.
|
|
||||||
|
|
||||||
Every record must supply its complete own identity before inheritance. Preserve
|
|
||||||
the child's identity fields and inherit only the parent's nonidentity payload;
|
|
||||||
inheritance cannot supply a missing child identity. Child payload fields take
|
|
||||||
precedence, mappings combine recursively, and nested sequences use the inheriting
|
|
||||||
destination's collection policy. Consume the record's directive before decoding.
|
|
||||||
|
|
||||||
Support one parent within the same identified sequence, including forward
|
|
||||||
references and chains. Missing parents and cycles, including self-reference,
|
|
||||||
fail the load. Cross-collection references, position-based references, multiple
|
|
||||||
parents, and inheritance between unidentified sequence members are outside this
|
|
||||||
scope. Existing string-form references between mapping siblings retain their
|
|
||||||
meaning; they do not become shorthand for sequence identity references.
|
|
||||||
|
|
||||||
Resolve relationships against records surviving source-tier assembly and selected
|
|
||||||
overlays. A higher layer can replace a parent before inheritance runs; removing
|
|
||||||
that parent makes a surviving reference fail. Whole-record upsert discards the
|
|
||||||
old record's directive along with its payload. Omitting `extends` in the
|
|
||||||
replacement record does not retain the displaced record's relationship. An
|
|
||||||
explicit reference to the record's own identity is a cycle, not a way to access
|
|
||||||
its lower-tier predecessor.
|
|
||||||
|
|
||||||
Reuse Kahn's algorithm with the existing stable canonical semantic-pointer key
|
|
||||||
for dependency-ready ordering. Build parent lookup from the sequence's effective
|
|
||||||
identity, and preserve the assembled list order independently of resolution
|
|
||||||
order. Enclosing relationships resolve before nested relationships. Parent
|
|
||||||
templates retain nested instructions for evaluation at each inheriting
|
|
||||||
destination, following the existing destination-local inheritance rule. Copied
|
|
||||||
collection identity, destination operation selection, and record-owned policy
|
|
||||||
transfer follow the rules above.
|
|
||||||
|
|
||||||
Record inheritance runs in the existing structural phase, before typed defaults,
|
|
||||||
decoding, CLI overrides, and application resolution. Those later stages do not
|
|
||||||
trigger another inheritance pass. Preserve inherited presence, origin and
|
|
||||||
history evidence, sensitivity, declaring resource contexts, and existing
|
|
||||||
expansion budgets just as for mapping-sibling inheritance. Identity lookup must
|
|
||||||
not expose identity values in diagnostics. Existing index-based inspection paths
|
|
||||||
remain sufficient; no new identity-selector path API is needed.
|
|
||||||
|
|
||||||
## Loading Lifecycle
|
|
||||||
|
|
||||||
Use the following order:
|
|
||||||
|
|
||||||
1. Assemble source tiers and selected overlays, resolving policy before each
|
|
||||||
tier's strict composition and applying collection operations at precedence
|
|
||||||
boundaries.
|
|
||||||
2. Resolve sibling inheritance in mappings and identified sequences, then apply
|
|
||||||
typed defaults and decode the assembled configuration.
|
|
||||||
3. Apply explicit CLI overrides to that configuration and check supplied values
|
|
||||||
against the application schema.
|
|
||||||
4. Run application resolution against the overridden configuration.
|
|
||||||
5. Resolve effective secret references, validate the final configuration including
|
|
||||||
application-registered identities, and publish it.
|
|
||||||
|
|
||||||
### Typed Defaults And Application Resolution
|
|
||||||
|
|
||||||
Source-backed underlays, including application-supplied mapping sources,
|
|
||||||
participate in sequence combination. Typed `Definition.Defaults` remains the
|
|
||||||
initial decoded state: supplied sequences replace default sequences. Source
|
|
||||||
append does not append to a typed default list.
|
|
||||||
|
|
||||||
Application resolution retains assignment and recorded-transformation semantics.
|
|
||||||
ConfigKit does not automatically combine a callback's assigned sequence with its
|
|
||||||
previous value. Application-registered identities receive final validation
|
|
||||||
independently of how defaults, CLI overrides, or callbacks produce their values.
|
|
||||||
|
|
||||||
### CLI Precedence
|
|
||||||
|
|
||||||
CLI overrides are the highest-precedence input to application resolution.
|
|
||||||
Supplied sequences replace whole sequences, including sequences inside supplied
|
|
||||||
mappings. Mappings retain recursive override behavior; empty sequences and
|
|
||||||
permitted nulls retain explicit replacement meaning. Configuration append or
|
|
||||||
upsert does not reinterpret CLI assignments. Inheritance and defaulting do not
|
|
||||||
run again afterward: overriding assembled `[A, B]` with `[C]` presents `[C]` to
|
|
||||||
application resolution without inheritance reintroducing prior records.
|
|
||||||
|
|
||||||
Application resolution sees CLI values and their explicit-presence and source
|
|
||||||
metadata. `Resolution.Default` preserves explicit CLI values; normalization and
|
|
||||||
recorded application transformations may still change them. For example, a derived
|
|
||||||
URL can use a CLI hostname, while an explicit CLI URL can be preserved by an
|
|
||||||
absence-based default. Retain recording and provenance requirements for those
|
|
||||||
transformations.
|
|
||||||
|
|
||||||
Reject structural directives such as `extends` and `configkit` in CLI override
|
|
||||||
paths or supplied values. CLI input supplies application values after structural
|
|
||||||
processing; it cannot supply or cancel inheritance directives, change completed
|
|
||||||
relationships, or prevent earlier inheritance failures.
|
|
||||||
|
|
||||||
Typed decoding also precedes CLI application, so a CLI replacement cannot repair
|
|
||||||
an earlier decoding failure. A source string in an integer field fails before a
|
|
||||||
valid CLI integer can apply. This changes current override-before-decoding
|
|
||||||
behavior. Semantic validation remains after overrides and application resolution.
|
|
||||||
|
|
||||||
Secret lookup uses effective references after overrides and resolution. Neither
|
|
||||||
stage bypasses final application identity constraints or semantic validation.
|
|
||||||
|
|
||||||
## Validation And Diagnostics
|
|
||||||
|
|
||||||
### Structural Identity Checks
|
|
||||||
|
|
||||||
Allow item-local helper imports to complete and consume applicable non-main
|
|
||||||
source-version headers before checking payload identity for collection assembly.
|
|
||||||
A helper can supply an identity component; a consumed header cannot supply one
|
|
||||||
after removal.
|
|
||||||
|
|
||||||
Structural controls are likewise unavailable as source identity components.
|
|
||||||
Check identity against application payload fields, excluding fields acting as
|
|
||||||
structural directives even when the engine retains them for later processing.
|
|
||||||
For example, `key: extends` with an item containing `extends: null` does not
|
|
||||||
supply a null identity: that field cancels inheritance and is consumed. Report
|
|
||||||
the ordinary missing-identity-component error at the collection/item path using
|
|
||||||
available origins, without exposing values. Do not defer this failure until
|
|
||||||
directive consumption or final application validation.
|
|
||||||
|
|
||||||
This rule follows a field's structural role; it does not introduce a broader
|
|
||||||
restriction on unrelated literal field names. Final application identity checks
|
|
||||||
continue to examine the effective typed payload under their existing rules.
|
|
||||||
|
|
||||||
Validate each identified contribution before combination and check uniqueness in
|
|
||||||
the resulting identified collection, including inheritance operations. Upsert
|
|
||||||
matches across precedence are replacements; duplicates within a contribution
|
|
||||||
remain errors. Later inheritance, typed defaults, or callbacks cannot repair an
|
|
||||||
identity missing at an earlier assembly boundary.
|
|
||||||
|
|
||||||
YAML-only identities are enforced during structural composition, precedence
|
|
||||||
assembly, and identified inheritance operations. They have no final-result
|
|
||||||
guarantee after typed defaulting, CLI overrides, or application resolution. Those
|
|
||||||
stages can produce duplicate identities or otherwise violate a YAML-only identity.
|
|
||||||
Applications needing a final guarantee register the constraint through the
|
|
||||||
application-owned interface.
|
|
||||||
|
|
||||||
Adding record inheritance does not expand this guarantee: a YAML declaration
|
|
||||||
can enable parent lookup without imposing final validation on values later
|
|
||||||
produced by defaults, CLI overrides, or application resolution.
|
|
||||||
|
|
||||||
### Final Application Constraints
|
|
||||||
|
|
||||||
On successful loading, final effective collections covered by application
|
|
||||||
registrations satisfy their declared identity constraints. Retain assembly-time
|
|
||||||
checks and validate final effective sequences before publication, after defaults,
|
|
||||||
inheritance, overrides, application resolution, and secret lookup.
|
|
||||||
|
|
||||||
This covers both exact and dynamic application registrations. Evaluate dynamic
|
|
||||||
selectors against the final effective configuration, with independent validation
|
|
||||||
for each concrete matched sequence as described under
|
|
||||||
[Matching Across The Loading Lifecycle](#matching-across-the-loading-lifecycle).
|
|
||||||
|
|
||||||
ConfigKit performs these identity checks; consumers register the constraint
|
|
||||||
without needing to repeat duplicate detection in `Definition.Validate`.
|
|
||||||
Application-owned semantic validation continues to cover domain-specific rules.
|
|
||||||
|
|
||||||
This guarantee covers actual sequences, not path existence or allowed shape.
|
|
||||||
It does not require every intermediate callback state to satisfy the constraint
|
|
||||||
and does not cover caller mutations after loading returns. Final checks examine
|
|
||||||
effective typed values; assembly checks validate authored contributions. A final
|
|
||||||
scalar alone does not prove that a source explicitly supplied that field.
|
|
||||||
|
|
||||||
Use the existing canonical final-value representation and shared publication
|
|
||||||
path as the foundation. The guarantee requires neither a new mutation-history
|
|
||||||
system nor exact causal reconstruction for records supplied by defaults or
|
|
||||||
callbacks.
|
|
||||||
|
|
||||||
### Diagnostics And Inspection
|
|
||||||
|
|
||||||
Identity diagnostics identify collection and item paths using available origin
|
|
||||||
information without exposing identity values. Conflicting declarations identify
|
|
||||||
the collection destination and declaration locations.
|
|
||||||
|
|
||||||
For dynamic registrations, use concrete matched collection and item paths for
|
|
||||||
value diagnostics, with available origins and existing sensitivity rules.
|
|
||||||
Definition-time overlap errors identify the conflicting registrations; they do
|
|
||||||
not require or invent a concrete destination from a loaded document.
|
|
||||||
|
|
||||||
Correctness includes final values, projected item paths, source destinations,
|
|
||||||
provenance, history, sensitivity, and passive resource contexts. Appending can
|
|
||||||
change final indexes; replacing changes which contributions survive. Preserve
|
|
||||||
the existing loading and inspection contracts through both operations, including
|
|
||||||
policy ownership and inheritance transfer.
|
|
||||||
|
|
||||||
## Work Needed From The Baseline
|
|
||||||
|
|
||||||
The baseline needs the following connected capabilities to reach the target
|
|
||||||
state. Execution order and test sequencing belong to the
|
|
||||||
[implementation plan](implementation.md):
|
|
||||||
|
|
||||||
- **Declarations and public API:** extend application registration to the settled
|
|
||||||
identity model with one public registration type and definition field, an
|
|
||||||
exact-path convenience constructor, and an explicit-selector constructor.
|
|
||||||
Preserve literal `Path` semantics. Parse and consume `configkit.sequences` across
|
|
||||||
supported source forms and selected overlays; preserve placement-relative and
|
|
||||||
item-local scopes. Keep sequence declarations independent of filesystem import
|
|
||||||
enablement.
|
|
||||||
- **Identity and policy representation:** implement field-set comparison, typed
|
|
||||||
scalar identity tuples with exact numeric equality, declaration agreement,
|
|
||||||
pending declarations, explicit-mode persistence, replacement lifetime, and
|
|
||||||
record-owned policy transfer. Choose bookkeeping proportional to these semantics.
|
|
||||||
Handle descendant collection removal through ancestor replacement, preserving
|
|
||||||
independently declared modes and application constraints while discarding
|
|
||||||
removed collection identity and record-owned policies.
|
|
||||||
- **Registration matching:** support literal and arbitrary-mapping-key segments,
|
|
||||||
detect incompatible selector overlap during definition validation, and resolve
|
|
||||||
applicable constraints at concrete placed destinations. Reuse the identity
|
|
||||||
rules and validator for exact and dynamic registrations. Keep matching separate
|
|
||||||
from source discovery and from YAML declaration ownership.
|
|
||||||
- **Assembly handoffs:** make accumulated lower policy available before higher-tier
|
|
||||||
strict composition. Separate source acquisition from policy-aware composition
|
|
||||||
or otherwise revise assembly sequencing; current main-before-underlay composition
|
|
||||||
is not a design constraint. Preserve distinctions among acquisition order,
|
|
||||||
dependency-ready scheduling within tiers, and precedence order. Account for
|
|
||||||
source records, callbacks, main-version checks, and failure diagnostics. Retain
|
|
||||||
version ownership and validation semantics; higher replacement cannot hide
|
|
||||||
lower-tier structural failures.
|
|
||||||
- **Combination and inheritance:** implement the three operations with strict peer
|
|
||||||
handling, whole-record replacement, presence rules, stable ordering, and the
|
|
||||||
destination policy rules for inheritance. Extend sibling-scope discovery and
|
|
||||||
parent lookup to identified sequences, validate mapping-form references, and
|
|
||||||
preserve child identities while copying parent payloads. Reuse the shared
|
|
||||||
dependency-ready scheduler and destination-local nested resolution. Preserve
|
|
||||||
provenance, history, sensitivity, paths, and resource contexts as records survive,
|
|
||||||
move, or disappear.
|
|
||||||
- **Lifecycle and validation:** move CLI application after inheritance and typed
|
|
||||||
defaults/decoding, preserve explicit-presence and source metadata for application
|
|
||||||
resolution, reject CLI structural directives, and add final application identity
|
|
||||||
validation through the shared publication path. Keep YAML-only enforcement
|
|
||||||
within structural operations. Match dynamic registrations during assembly and
|
|
||||||
inheritance, and against the final effective configuration rather than a saved
|
|
||||||
list of source-backed destinations.
|
|
||||||
Exclude structural controls from source identity checks even while retaining
|
|
||||||
those controls for later inheritance processing.
|
|
||||||
- **Documentation and verification:** exercise ordinary and layered loading,
|
|
||||||
imported and inline collections, item-local scopes, version-header consumption,
|
|
||||||
selected overlays, inheritance, typed defaults, CLI, callbacks, and inspection.
|
|
||||||
Cover single and compound parent references, scalar equality, forward references
|
|
||||||
and chains, missing parents and cycles, parent replacement/removal, child identity
|
|
||||||
preservation, nested destination policy, cancellation, and stable output order.
|
|
||||||
Verify helper-composed references, overlapping reference fields, missing or extra
|
|
||||||
fields, and replacement of references together with their containing records.
|
|
||||||
Verify independent namespace uniqueness, root and namespaced registrations,
|
|
||||||
literal punctuation keys, equivalent and incompatible overlaps, and conflicts
|
|
||||||
detected even without a matching input destination. Cover namespaces introduced
|
|
||||||
by imports, placement, higher tiers, inheritance, defaults, CLI, and callbacks;
|
|
||||||
preserve ordinary strict-peer and upsert behavior. Check YAML disagreement under
|
|
||||||
replace, harmless absent or empty collections, schema-owned shape requirements,
|
|
||||||
unrelated sequences remaining unselected, and concrete-path diagnostics.
|
|
||||||
Verify both constructors feed the same registration model, including mixed
|
|
||||||
exact and dynamic registrations, compound identities, and definition-time
|
|
||||||
rejection of empty identity field sets.
|
|
||||||
Cover ancestor replacement followed by collection reintroduction, including a
|
|
||||||
surviving append mode with and without renewed identity, explicit replacement,
|
|
||||||
and application-registered identity. Verify that `extends: null` cannot supply
|
|
||||||
an identity component and that structural-control exclusion uses ordinary
|
|
||||||
value-free missing-identity diagnostics.
|
|
||||||
Update the public contract and consumer examples when behavior lands.
|
|
||||||
|
|
||||||
The collection-policy and CLI-ordering decisions are accepted in
|
|
||||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) for collection policy and
|
|
||||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) for CLI ordering.
|
|
||||||
Their scoped supersession is recorded in the affected earlier ADRs, and the
|
|
||||||
[architecture policy](../policy/architecture.md) reflects the accepted decisions.
|
|
||||||
These govern the target architecture; the configuration contract continues to
|
|
||||||
describe implemented behavior until the feature lands. This roadmap does not
|
|
||||||
select migration machinery.
|
|
||||||
|
|
||||||
ADR 0017 now includes inheritance within identified sequences;
|
|
||||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records that scope extension in its
|
|
||||||
status while preserving its decision content. The architecture policy is aligned.
|
|
||||||
Dynamic selectors extend the destinations covered by application registration;
|
|
||||||
they retain the accepted application authority and final-validation boundary.
|
|
||||||
|
|
||||||
No open feature decisions remain. The implementation plan translates this end
|
|
||||||
state into reviewable stages without adding guarantees, restrictions, or
|
|
||||||
combination semantics.
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -31,13 +31,23 @@ type SequenceCatalogEntry struct {
|
|||||||
Credential configkit.Secret `yaml:"credential"`
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSequenceCatalog assembles imported main fragments over a file-backed
|
// LoadSequenceCatalog searches its caller-selected main path before a custom
|
||||||
// underlay, applies one selected collection overlay, then resolves and validates
|
// fallback directory, assembles imported fragments over a file-backed underlay,
|
||||||
// the resulting application configuration.
|
// applies one selected collection overlay, then resolves and validates once.
|
||||||
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
||||||
root := filepath.Dir(mainPath)
|
absoluteMain, err := filepath.Abs(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root := filepath.Dir(absoluteMain)
|
||||||
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||||
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
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]{
|
definition := configkit.Definition[SequenceCatalogConfig]{
|
||||||
SequenceIdentities: []configkit.SequenceIdentity{
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
@@ -51,8 +61,9 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
|
|||||||
Validate: validateSequenceCatalog,
|
Validate: validateSequenceCatalog,
|
||||||
}
|
}
|
||||||
return configkit.LoadLayered(configkit.Input{
|
return configkit.LoadLayered(configkit.Input{
|
||||||
Source: filesystem.File(filepath.Base(mainPath)),
|
Source: mainSearch,
|
||||||
Imports: true,
|
Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{Root: root},
|
||||||
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
Source: filesystem.File("underlay.yml"),
|
Source: filesystem.File("underlay.yml"),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||||
@@ -125,20 +123,61 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
|||||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
||||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
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:
|
imports:
|
||||||
- dir: recursive-files
|
- dir: parts
|
||||||
`)
|
`)
|
||||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
|
||||||
recursive := true
|
|
||||||
patterns := []string{"*.cfg"}
|
recursive := true
|
||||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
patterns := []string{"*.cfg"}
|
||||||
recursive = false
|
definition := Definition[config]{Version: &VersionRule{
|
||||||
patterns[0] = "["
|
Path: Keys("version"),
|
||||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
Check: func(version Version, _ *Validation) error {
|
||||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
if version != (Version{Present: true, Text: "v1"}) {
|
||||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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") })
|
||||||
|
})
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,7 @@ type collectionPolicy struct {
|
|||||||
type collectionPolicyResolution struct {
|
type collectionPolicyResolution struct {
|
||||||
policy collectionPolicy
|
policy collectionPolicy
|
||||||
operation sequenceMode
|
operation sequenceMode
|
||||||
declarations []sequenceDeclarationSpec
|
|
||||||
declarationsPresent bool
|
|
||||||
identityDeclarationPresent bool
|
|
||||||
modeDeclarationPresent bool
|
modeDeclarationPresent bool
|
||||||
applicationIdentityFields []string
|
|
||||||
applicationIdentityPresent bool
|
applicationIdentityPresent bool
|
||||||
effectiveIdentityFields []string
|
effectiveIdentityFields []string
|
||||||
}
|
}
|
||||||
@@ -60,9 +56,6 @@ func resolveCollectionPolicy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
result := collectionPolicyResolution{
|
result := collectionPolicyResolution{
|
||||||
declarations: normalizedDeclarations,
|
|
||||||
declarationsPresent: len(normalizedDeclarations) != 0,
|
|
||||||
applicationIdentityFields: normalizedApplicationIdentityFields,
|
|
||||||
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +66,6 @@ func resolveCollectionPolicy(
|
|||||||
var declaredModeLocation *Location
|
var declaredModeLocation *Location
|
||||||
for _, declaration := range normalizedDeclarations {
|
for _, declaration := range normalizedDeclarations {
|
||||||
if declaration.identityPresent {
|
if declaration.identityPresent {
|
||||||
result.identityDeclarationPresent = true
|
|
||||||
if declaredIdentityFields == nil {
|
if declaredIdentityFields == nil {
|
||||||
declaredIdentityFields = cloneStrings(declaration.identityFields)
|
declaredIdentityFields = cloneStrings(declaration.identityFields)
|
||||||
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)
|
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if !resolved.declarationsPresent || !resolved.identityDeclarationPresent || !resolved.modeDeclarationPresent {
|
if !resolved.modeDeclarationPresent {
|
||||||
t.Fatalf("declaration presence was lost: %#v", resolved)
|
t.Fatalf("mode declaration was lost: %#v", resolved)
|
||||||
}
|
}
|
||||||
wantFields := []string{"id", "namespace"}
|
wantFields := []string{"id", "namespace"}
|
||||||
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
|
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
|
||||||
@@ -31,9 +31,6 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
|||||||
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
|
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)
|
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
|
||||||
}
|
}
|
||||||
if len(resolved.declarations) != 3 || resolved.declarations[0].identityLocation == nil || resolved.declarations[2].location.Line == 0 {
|
|
||||||
t.Fatalf("declaration locations were not retained: %#v", resolved.declarations)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {
|
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|||||||
// operation at that exact path.
|
// operation at that exact path.
|
||||||
type collectionReplacementPaths map[string]bool
|
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 {
|
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
|
||||||
for length := 0; length <= len(path.segments); length++ {
|
for length := 0; length <= len(path.segments); length++ {
|
||||||
prefix := Path{segments: path.segments[:length]}
|
prefix := Path{segments: path.segments[:length]}
|
||||||
@@ -27,6 +44,26 @@ func lowerCollectionPoliciesForClaims(
|
|||||||
declarations map[string][]sequenceDeclarationSpec,
|
declarations map[string][]sequenceDeclarationSpec,
|
||||||
application sequencePolicy,
|
application sequencePolicy,
|
||||||
) (collectionPolicyScope, collectionReplacementPaths) {
|
) (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))
|
byPath := make(map[string]compositionClaim, len(claims))
|
||||||
rootPath := ""
|
rootPath := ""
|
||||||
if len(claims) != 0 {
|
if len(claims) != 0 {
|
||||||
@@ -62,14 +99,7 @@ func lowerCollectionPoliciesForClaims(
|
|||||||
replaced[key] = false
|
replaced[key] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result := cloneCollectionPolicyScope(lower)
|
return replaced
|
||||||
for key, entry := range result.collections {
|
|
||||||
if replaced.contains(entry.destination, true) {
|
|
||||||
clearCollectionIdentity(&entry.policy)
|
|
||||||
result.collections[key] = entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, replaced
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCollectionIdentity(policy *collectionPolicy) {
|
func clearCollectionIdentity(policy *collectionPolicy) {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
|
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
|
||||||
@@ -109,3 +112,239 @@ func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -39,12 +43,20 @@ type sequenceHistoryContribution struct {
|
|||||||
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
||||||
// history applications are separate from its root and remain unchanged.
|
// history applications are separate from its root and remain unchanged.
|
||||||
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;
|
||||||
@@ -89,19 +101,18 @@ type sequenceRange struct {
|
|||||||
projection sequenceItemProjectionIndex
|
projection sequenceItemProjectionIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (assigned sequenceRange) projections() []sequenceItemProjection {
|
|
||||||
if assigned.items != nil {
|
|
||||||
return append([]sequenceItemProjection{}, assigned.items...)
|
|
||||||
}
|
|
||||||
return sequenceItemProjections(assigned.end-assigned.start, assigned.start)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
|
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
|
||||||
if assigned.projection.outputByInput != nil {
|
if assigned.projection.outputByInput != nil {
|
||||||
return assigned.projection.outputForInput(inputIndex)
|
return assigned.projection.outputForInput(inputIndex)
|
||||||
}
|
}
|
||||||
if assigned.items != nil {
|
if assigned.items != nil {
|
||||||
return newSequenceItemProjectionIndex(assigned.items).outputForInput(inputIndex)
|
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 {
|
if inputIndex < 0 || inputIndex >= assigned.end-assigned.start {
|
||||||
return 0, false
|
return 0, false
|
||||||
@@ -114,7 +125,13 @@ func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
|||||||
return assigned.projection.inputForOutput(outputIndex)
|
return assigned.projection.inputForOutput(outputIndex)
|
||||||
}
|
}
|
||||||
if assigned.items != nil {
|
if assigned.items != nil {
|
||||||
return newSequenceItemProjectionIndex(assigned.items).inputForOutput(outputIndex)
|
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 {
|
if outputIndex < assigned.start || outputIndex >= assigned.end {
|
||||||
return 0, false
|
return 0, false
|
||||||
@@ -123,8 +140,8 @@ func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func indexSequenceRange(assigned sequenceRange) sequenceRange {
|
func indexSequenceRange(assigned sequenceRange) sequenceRange {
|
||||||
if assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
if assigned.items != nil && assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
||||||
assigned.projection = newSequenceItemProjectionIndex(assigned.projections())
|
assigned.projection = newSequenceItemProjectionIndex(assigned.items)
|
||||||
}
|
}
|
||||||
return assigned
|
return assigned
|
||||||
}
|
}
|
||||||
@@ -296,14 +313,10 @@ func resolveStrictCollectionPolicies(
|
|||||||
valuePresent := make(map[string]bool)
|
valuePresent := make(map[string]bool)
|
||||||
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
|
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
|
||||||
for _, claim := range claims {
|
for _, claim := range claims {
|
||||||
if len(lower.collections) != 0 {
|
|
||||||
valuePresent[claim.path.String()] = true
|
|
||||||
}
|
|
||||||
if claim.kind != document.Sequence {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := claim.path.String()
|
key := claim.path.String()
|
||||||
if !seen[key] {
|
valuePresent[key] = true
|
||||||
|
_, hasLower := lower.collections[key]
|
||||||
|
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
paths = append(paths, claim.path.clone())
|
paths = append(paths, claim.path.clone())
|
||||||
}
|
}
|
||||||
@@ -318,21 +331,18 @@ func resolveStrictCollectionPolicies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
identityFieldsByPath := make(map[string][]string)
|
identityFieldsByPath := make(map[string][]string)
|
||||||
effectiveLower := lower
|
replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||||
if len(lower.collections) != 0 {
|
lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
|
||||||
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
var collectionPolicies collectionPolicyScope
|
||||||
}
|
|
||||||
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
|
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
key := path.String()
|
key := path.String()
|
||||||
currentDeclarations := declarationsByPath[key]
|
currentDeclarations := declarationsByPath[key]
|
||||||
|
|
||||||
lowerEntry, hasLower := effectiveLower.collections[key]
|
lowerPolicyValue, hasLower := lowerView.at(path)
|
||||||
var lowerPolicy *collectionPolicy
|
var lowerPolicy *collectionPolicy
|
||||||
if hasLower {
|
if hasLower {
|
||||||
copy := cloneCollectionPolicy(lowerEntry.policy)
|
lowerPolicy = &lowerPolicyValue
|
||||||
lowerPolicy = ©
|
|
||||||
}
|
}
|
||||||
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
|
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
|
||||||
|
|
||||||
@@ -349,7 +359,7 @@ func resolveStrictCollectionPolicies(
|
|||||||
if hasLower && !valuePresent[key] {
|
if hasLower && !valuePresent[key] {
|
||||||
// A declaration without a new value changes policy metadata but
|
// A declaration without a new value changes policy metadata but
|
||||||
// leaves retained record ownership untouched.
|
// leaves retained record ownership untouched.
|
||||||
policyForPath = cloneCollectionPolicy(lowerEntry.policy)
|
policyForPath = cloneCollectionPolicy(lowerPolicyValue)
|
||||||
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
|
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
|
||||||
policyForPath.identityPresent = resolution.policy.identityPresent
|
policyForPath.identityPresent = resolution.policy.identityPresent
|
||||||
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
|
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
|
||||||
@@ -358,7 +368,7 @@ func resolveStrictCollectionPolicies(
|
|||||||
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
|
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
|
||||||
}
|
}
|
||||||
if collectionPolicies.collections == nil {
|
if collectionPolicies.collections == nil {
|
||||||
collectionPolicies.collections = make(map[string]collectionPolicyEntry)
|
collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
|
||||||
}
|
}
|
||||||
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
|
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
|
||||||
}
|
}
|
||||||
@@ -459,8 +469,8 @@ func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
|||||||
if assigned.items != nil {
|
if assigned.items != nil {
|
||||||
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
|
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
|
||||||
}
|
}
|
||||||
cloned[i].projection.outputByInput = append([]int(nil), assigned.projection.outputByInput...)
|
cloned[i].projection.outputByInput = maps.Clone(assigned.projection.outputByInput)
|
||||||
cloned[i].projection.inputByOutput = append([]int(nil), assigned.projection.inputByOutput...)
|
cloned[i].projection.inputByOutput = maps.Clone(assigned.projection.inputByOutput)
|
||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
@@ -474,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 {
|
||||||
@@ -502,9 +497,15 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
|
|||||||
origins := make(map[string]Origin)
|
origins := make(map[string]Origin)
|
||||||
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
|
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
|
||||||
for _, assigned := range ranges {
|
for _, assigned := range ranges {
|
||||||
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], sequenceHistoryContribution{
|
contribution := sequenceHistoryContribution{
|
||||||
path: assigned.path.clone(), mode: sequenceHistoryMapped, items: assigned.projections(),
|
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 {
|
||||||
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}
|
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}
|
||||||
|
|||||||
@@ -489,6 +489,96 @@ func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
|
||||||
path := Keys("records")
|
path := Keys("records")
|
||||||
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
|
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.Elem().Set(decoded)
|
result := reflect.New(pointers[i].typeOf.Elem())
|
||||||
return result, nil
|
result.Elem().Set(decoded)
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ import (
|
|||||||
"testing"
|
"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) {
|
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
|
||||||
names := []string{"a-base", "b-independent", "z-dependent"}
|
names := []string{"a-base", "b-independent", "z-dependent"}
|
||||||
keys := make(map[string]importNodeKey, len(names))
|
keys := make(map[string]importNodeKey, len(names))
|
||||||
@@ -33,7 +56,7 @@ func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.
|
|||||||
}
|
}
|
||||||
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
||||||
|
|
||||||
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, graph)
|
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, indexImportDependencies(graph))
|
||||||
got := make([]string, len(scheduled))
|
got := make([]string, len(scheduled))
|
||||||
for index, contribution := range scheduled {
|
for index, contribution := range scheduled {
|
||||||
got[index] = contribution.identity.resourcePath[1:]
|
got[index] = contribution.identity.resourcePath[1:]
|
||||||
|
|||||||
@@ -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 := testAcquireUnderlayGroup(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 := testAcquireUnderlayGroup(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 := testAcquireUnderlayGroup(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 := testAcquireUnderlayGroup(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)
|
||||||
}
|
}
|
||||||
@@ -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 := testAcquireUnderlayGroup(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,7 +440,7 @@ 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}
|
||||||
input, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
input, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assembly diagnostics = %#v", diagnostics)
|
t.Fatalf("assembly diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -443,3 +452,15 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
|||||||
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"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}}}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type rootDirectiveExtraction struct {
|
type rootDirectiveExtraction struct {
|
||||||
body *document.Node
|
body *document.Node
|
||||||
imports []importDeclarationSpec
|
imports []importDeclarationSpec
|
||||||
sequences []sequenceDeclarationSpec
|
sequences []sequenceDeclarationSpec
|
||||||
importsPresent bool
|
|
||||||
sequencesPresent bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||||
@@ -74,7 +72,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
|
|||||||
fieldPath := directivePath.Key(entry.Key)
|
fieldPath := directivePath.Key(entry.Key)
|
||||||
switch entry.Key {
|
switch entry.Key {
|
||||||
case "imports":
|
case "imports":
|
||||||
extracted.importsPresent = true
|
|
||||||
if context == rootDirectiveContextOverlay {
|
if context == rootDirectiveContextOverlay {
|
||||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
||||||
continue
|
continue
|
||||||
@@ -87,7 +84,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
|
|||||||
extracted.imports = append(extracted.imports, parsed...)
|
extracted.imports = append(extracted.imports, parsed...)
|
||||||
diagnostics = append(diagnostics, issues...)
|
diagnostics = append(diagnostics, issues...)
|
||||||
case "sequences":
|
case "sequences":
|
||||||
extracted.sequencesPresent = true
|
|
||||||
if context == rootDirectiveContextImportsOnly {
|
if context == rootDirectiveContextImportsOnly {
|
||||||
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 3 {
|
if len(selection.candidates) != 3 {
|
||||||
t.Fatalf("selection = %#v, want three unioned matches", selection)
|
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" {
|
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
|
||||||
@@ -82,7 +82,7 @@ func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics = resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
|||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
yaml string
|
yaml string
|
||||||
defaults importDefaults
|
defaults ImportDefaults
|
||||||
wantPaths []string
|
wantPaths []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -280,13 +280,13 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "explicit false overrides application recursion",
|
name: "explicit false overrides application recursion",
|
||||||
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
|
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
|
||||||
defaults: importDefaults{recursive: boolPointer(true)},
|
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||||
wantPaths: []string{"direct.yaml"},
|
wantPaths: []string{"direct.yaml"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "application default enables recursion for depth",
|
name: "application default enables recursion for depth",
|
||||||
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
|
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
|
||||||
defaults: importDefaults{recursive: boolPointer(true)},
|
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||||
wantPaths: []string{"child.yaml", "direct.yaml"},
|
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -347,13 +347,13 @@ func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
|
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
|
||||||
if len(diagnostics) != 0 || selection.outcome != importSelectionNone || len(selection.candidates) != 0 {
|
if len(diagnostics) != 0 || len(selection.candidates) != 0 {
|
||||||
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
|
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
}
|
}
|
||||||
if accounting.entries != 5 {
|
if accounting.entries != 5 {
|
||||||
|
|||||||
540
internal/engine/discovery.go
Normal file
540
internal/engine/discovery.go
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search is an immutable ordered description of possible host-file inputs.
|
||||||
|
// It does not access the filesystem until a later loading integration consumes it.
|
||||||
|
type Search struct {
|
||||||
|
entries []searchEntry
|
||||||
|
issues []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilenameGroup is an immutable ordered group used by a directory search.
|
||||||
|
type FilenameGroup struct {
|
||||||
|
kind filenameGroupKind
|
||||||
|
names []string
|
||||||
|
issues []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectorySearchOptions controls the filename groups used by SearchDirectory.
|
||||||
|
// A nil Filenames slice selects the default groups; a non-nil empty slice selects none.
|
||||||
|
type DirectorySearchOptions struct {
|
||||||
|
Filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOptions controls whether a later source consumption requires a match.
|
||||||
|
type SearchOptions struct {
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchConfig creates a source that selects the first matching host file when
|
||||||
|
// consumed. Description and option errors are reported during loading.
|
||||||
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||||
|
captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)}
|
||||||
|
source := Source{kind: sourceSearch, search: &captured, searchApp: application}
|
||||||
|
if len(supplied) > 1 {
|
||||||
|
source.searchIssues = []string{"SearchConfig accepts at most one options value"}
|
||||||
|
} else if len(supplied) == 1 {
|
||||||
|
source.searchRequired = supplied[0].Required
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
type filenameGroupKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
filenameGroupEmpty filenameGroupKind = iota
|
||||||
|
filenameGroupNamed
|
||||||
|
filenameGroupGeneric
|
||||||
|
filenameGroupCustom
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchEntryKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
searchEntryPath searchEntryKind = iota + 1
|
||||||
|
searchEntryDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
type directoryBaseKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
directoryBaseLiteral directoryBaseKind = iota
|
||||||
|
directoryBaseUnixLocal
|
||||||
|
directoryBaseUnixEtc
|
||||||
|
directoryBaseUserConfig
|
||||||
|
directoryBaseHome
|
||||||
|
directoryBaseWorking
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchEntry struct {
|
||||||
|
kind searchEntryKind
|
||||||
|
path string
|
||||||
|
baseKind directoryBaseKind
|
||||||
|
directory string
|
||||||
|
appendSlug bool
|
||||||
|
slugPrefix string
|
||||||
|
filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||||
|
func NamedFilenames() FilenameGroup {
|
||||||
|
return FilenameGroup{kind: filenameGroupNamed}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||||
|
func GenericFilenames() FilenameGroup {
|
||||||
|
return FilenameGroup{kind: filenameGroupGeneric}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomFilenames selects literal filename components in the supplied order.
|
||||||
|
func CustomFilenames(names ...string) FilenameGroup {
|
||||||
|
group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)}
|
||||||
|
for _, name := range group.names {
|
||||||
|
if !validFilenameComponent(name) {
|
||||||
|
group.issues = append(group.issues, "custom filename must be a usable host filename component")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDirectory searches the supplied directory using the selected filename groups.
|
||||||
|
// With omitted options or nil Filenames, it selects Named then Generic filenames.
|
||||||
|
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||||
|
search := Search{}
|
||||||
|
if len(supplied) > 1 {
|
||||||
|
search.issues = append(search.issues, "directory search accepts at most one options value")
|
||||||
|
}
|
||||||
|
var groups []FilenameGroup
|
||||||
|
if len(supplied) == 0 || supplied[0].Filenames == nil {
|
||||||
|
groups = []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||||
|
} else {
|
||||||
|
groups = cloneFilenameGroups(supplied[0].Filenames)
|
||||||
|
}
|
||||||
|
search.entries = append(search.entries, searchEntry{
|
||||||
|
kind: searchEntryDirectory, baseKind: directoryBaseLiteral,
|
||||||
|
directory: directory, filenames: groups,
|
||||||
|
})
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths describes literal host-file candidates in the supplied order.
|
||||||
|
func Paths(paths ...string) Search {
|
||||||
|
search := Search{entries: make([]searchEntry, 0, len(paths))}
|
||||||
|
for _, path := range append([]string(nil), paths...) {
|
||||||
|
search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path})
|
||||||
|
}
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searches concatenates descriptions in order and snapshots their entries.
|
||||||
|
func Searches(searches ...Search) Search {
|
||||||
|
result := Search{}
|
||||||
|
for _, search := range append([]Search(nil), searches...) {
|
||||||
|
result.entries = append(result.entries, cloneSearchEntries(search.entries)...)
|
||||||
|
result.issues = append(result.issues, search.issues...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||||
|
func UnixSystemDirectories() Search {
|
||||||
|
namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||||
|
return Search{entries: []searchEntry{
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserConfigDirectory describes the Go user-configuration directory searches.
|
||||||
|
func UserConfigDirectory() Search {
|
||||||
|
return Search{entries: []searchEntry{
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomeDirectory describes the Go home-directory searches.
|
||||||
|
func HomeDirectory() Search {
|
||||||
|
return Search{entries: []searchEntry{
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkingDirectory describes application-named files in the current directory.
|
||||||
|
func WorkingDirectory() Search {
|
||||||
|
return Search{entries: []searchEntry{{
|
||||||
|
kind: searchEntryDirectory, baseKind: directoryBaseWorking,
|
||||||
|
filenames: []FilenameGroup{NamedFilenames()},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFilenameGroups() []FilenameGroup {
|
||||||
|
return []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup {
|
||||||
|
if groups == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]FilenameGroup, len(groups))
|
||||||
|
for i, group := range groups {
|
||||||
|
cloned[i] = FilenameGroup{
|
||||||
|
kind: group.kind, names: append([]string(nil), group.names...),
|
||||||
|
issues: append([]string(nil), group.issues...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSearchEntries(entries []searchEntry) []searchEntry {
|
||||||
|
if entries == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]searchEntry, len(entries))
|
||||||
|
for i, entry := range entries {
|
||||||
|
cloned[i] = entry
|
||||||
|
cloned[i].filenames = cloneFilenameGroups(entry.filenames)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSearch(search Search, application string) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, issue := range search.issues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
|
}
|
||||||
|
slugNeeded := false
|
||||||
|
for _, entry := range search.entries {
|
||||||
|
switch entry.kind {
|
||||||
|
case searchEntryPath:
|
||||||
|
if entry.path == "" {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty"))
|
||||||
|
}
|
||||||
|
case searchEntryDirectory:
|
||||||
|
if entry.baseKind == directoryBaseLiteral && entry.directory == "" {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty"))
|
||||||
|
}
|
||||||
|
if entry.appendSlug {
|
||||||
|
slugNeeded = true
|
||||||
|
}
|
||||||
|
for _, group := range entry.filenames {
|
||||||
|
switch group.kind {
|
||||||
|
case filenameGroupEmpty, filenameGroupGeneric:
|
||||||
|
case filenameGroupNamed:
|
||||||
|
slugNeeded = true
|
||||||
|
case filenameGroupCustom:
|
||||||
|
default:
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid"))
|
||||||
|
}
|
||||||
|
for _, issue := range group.issues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slugNeeded && !validFilenameComponent(application) {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component"))
|
||||||
|
}
|
||||||
|
return canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFilenameComponent(value string) bool {
|
||||||
|
if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if !filepath.IsLocal(value) || strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character < 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var errUnixSystemPresetUnavailable = errors.New("Unix system directories are unavailable on this platform")
|
||||||
|
|
||||||
|
type discoveryDirectoryHelpers struct {
|
||||||
|
userConfigDir func() (string, error)
|
||||||
|
userHomeDir func() (string, error)
|
||||||
|
workingDir func() (string, error)
|
||||||
|
unixSupported func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchCandidateIterator struct {
|
||||||
|
search Search
|
||||||
|
application string
|
||||||
|
helpers discoveryDirectoryHelpers
|
||||||
|
entryIndex int
|
||||||
|
groupIndex int
|
||||||
|
nameIndex int
|
||||||
|
directory string
|
||||||
|
dirReady bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSearchCandidateIterator(search Search, application string, helpers discoveryDirectoryHelpers) (*searchCandidateIterator, []Diagnostic) {
|
||||||
|
if diagnostics := validateSearch(search, application); len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
defaults := defaultDiscoveryDirectoryHelpers()
|
||||||
|
if helpers.userConfigDir == nil {
|
||||||
|
helpers.userConfigDir = defaults.userConfigDir
|
||||||
|
}
|
||||||
|
if helpers.userHomeDir == nil {
|
||||||
|
helpers.userHomeDir = defaults.userHomeDir
|
||||||
|
}
|
||||||
|
if helpers.workingDir == nil {
|
||||||
|
helpers.workingDir = defaults.workingDir
|
||||||
|
}
|
||||||
|
if helpers.unixSupported == nil {
|
||||||
|
helpers.unixSupported = defaults.unixSupported
|
||||||
|
}
|
||||||
|
return &searchCandidateIterator{search: search, application: application, helpers: helpers}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultDiscoveryDirectoryHelpers() discoveryDirectoryHelpers {
|
||||||
|
return discoveryDirectoryHelpers{
|
||||||
|
userConfigDir: os.UserConfigDir,
|
||||||
|
userHomeDir: os.UserHomeDir,
|
||||||
|
workingDir: os.Getwd,
|
||||||
|
unixSupported: unixSystemPresetSupported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next expands one candidate at a time, resolving only the directory currently
|
||||||
|
// reached. Directory filenames are visited before moving to the next directory.
|
||||||
|
func (iterator *searchCandidateIterator) next() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
if iterator.dirReady {
|
||||||
|
entry := iterator.search.entries[iterator.entryIndex]
|
||||||
|
for iterator.groupIndex < len(entry.filenames) {
|
||||||
|
group := entry.filenames[iterator.groupIndex]
|
||||||
|
count := filenameGroupLength(group)
|
||||||
|
if iterator.nameIndex < count {
|
||||||
|
name := filenameGroupAt(group, iterator.nameIndex, iterator.application)
|
||||||
|
iterator.nameIndex++
|
||||||
|
return filepath.Join(iterator.directory, name), true, nil
|
||||||
|
}
|
||||||
|
iterator.groupIndex++
|
||||||
|
iterator.nameIndex = 0
|
||||||
|
}
|
||||||
|
iterator.dirReady = false
|
||||||
|
iterator.entryIndex++
|
||||||
|
}
|
||||||
|
if iterator.entryIndex >= len(iterator.search.entries) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
entry := iterator.search.entries[iterator.entryIndex]
|
||||||
|
if entry.kind == searchEntryPath {
|
||||||
|
iterator.entryIndex++
|
||||||
|
path, err := iterator.absoluteCandidate(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
if !directoryHasCandidates(entry.filenames) {
|
||||||
|
iterator.entryIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
directory, err := iterator.resolveDirectory(entry)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
iterator.directory = directory
|
||||||
|
iterator.groupIndex = 0
|
||||||
|
iterator.nameIndex = 0
|
||||||
|
iterator.dirReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iterator *searchCandidateIterator) absoluteCandidate(path string) (string, error) {
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
}
|
||||||
|
// Drive-relative and drive-root-relative Windows paths need native
|
||||||
|
// resolution; joining a rooted path to the whole working directory changes it.
|
||||||
|
if filepath.VolumeName(path) != "" || runtime.GOOS == "windows" && len(path) > 0 && os.IsPathSeparator(path[0]) {
|
||||||
|
return filepath.Abs(path)
|
||||||
|
}
|
||||||
|
working, err := iterator.helpers.workingDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(working, path)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iterator *searchCandidateIterator) resolveDirectory(entry searchEntry) (string, error) {
|
||||||
|
var base string
|
||||||
|
switch entry.baseKind {
|
||||||
|
case directoryBaseLiteral:
|
||||||
|
base = entry.directory
|
||||||
|
case directoryBaseUnixLocal:
|
||||||
|
if !iterator.helpers.unixSupported() {
|
||||||
|
return "", errUnixSystemPresetUnavailable
|
||||||
|
}
|
||||||
|
base = "/usr/local/etc"
|
||||||
|
case directoryBaseUnixEtc:
|
||||||
|
if !iterator.helpers.unixSupported() {
|
||||||
|
return "", errUnixSystemPresetUnavailable
|
||||||
|
}
|
||||||
|
base = "/etc"
|
||||||
|
case directoryBaseUserConfig:
|
||||||
|
var err error
|
||||||
|
base, err = iterator.helpers.userConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case directoryBaseHome:
|
||||||
|
var err error
|
||||||
|
base, err = iterator.helpers.userHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case directoryBaseWorking:
|
||||||
|
var err error
|
||||||
|
base, err = iterator.helpers.workingDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", errors.New("directory search base is invalid")
|
||||||
|
}
|
||||||
|
if entry.appendSlug {
|
||||||
|
base = filepath.Join(base, entry.slugPrefix+iterator.application)
|
||||||
|
}
|
||||||
|
if entry.baseKind == directoryBaseLiteral && !filepath.IsAbs(base) {
|
||||||
|
return iterator.absoluteCandidate(base)
|
||||||
|
}
|
||||||
|
return filepath.Clean(base), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func directoryHasCandidates(groups []FilenameGroup) bool {
|
||||||
|
for _, group := range groups {
|
||||||
|
if filenameGroupLength(group) != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameGroupLength(group FilenameGroup) int {
|
||||||
|
switch group.kind {
|
||||||
|
case filenameGroupNamed:
|
||||||
|
return 2
|
||||||
|
case filenameGroupGeneric:
|
||||||
|
return 4
|
||||||
|
case filenameGroupCustom:
|
||||||
|
return len(group.names)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameGroupAt(group FilenameGroup, index int, application string) string {
|
||||||
|
switch group.kind {
|
||||||
|
case filenameGroupNamed:
|
||||||
|
if index == 0 {
|
||||||
|
return application + ".yml"
|
||||||
|
}
|
||||||
|
return application + ".yaml"
|
||||||
|
case filenameGroupGeneric:
|
||||||
|
return [...]string{"config.yml", "config.yaml", "main.yml", "main.yaml"}[index]
|
||||||
|
case filenameGroupCustom:
|
||||||
|
return group.names[index]
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type discoveryHostProbe struct {
|
||||||
|
evalSymlinks func(string) (string, error)
|
||||||
|
stat func(string) (fs.FileInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultDiscoveryHostProbe() discoveryHostProbe {
|
||||||
|
return discoveryHostProbe{evalSymlinks: filepath.EvalSymlinks, stat: os.Stat}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSearchInput associates a terminal candidate failure with the same file
|
||||||
|
// evidence as ordinary acquisition. Exhaustion and base failures have no file.
|
||||||
|
func resolveSearchInput(source Source, sourceID string, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, *SourceInfo, []Diagnostic) {
|
||||||
|
selected, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, helpers, probe)
|
||||||
|
if len(diagnostics) == 0 || selected.kind != sourceFile {
|
||||||
|
return selected, nil, diagnostics
|
||||||
|
}
|
||||||
|
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerBase, Kind: SourceKindFile}
|
||||||
|
for index := range diagnostics {
|
||||||
|
diagnostics[index].Primary = &Location{SourceID: sourceID}
|
||||||
|
}
|
||||||
|
return selected, info, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSearchSource selects one ordinary file source. A nil description leaves
|
||||||
|
// an existing source untouched; the selected file is read later by readSource.
|
||||||
|
// A terminal candidate failure returns its file descriptor for error evidence.
|
||||||
|
func resolveSearchSource(source Source, description *Search, application string, required bool, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, []Diagnostic) {
|
||||||
|
if description == nil {
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(*description, application, helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return Source{}, diagnostics
|
||||||
|
}
|
||||||
|
defaults := defaultDiscoveryHostProbe()
|
||||||
|
if probe.evalSymlinks == nil {
|
||||||
|
probe.evalSymlinks = defaults.evalSymlinks
|
||||||
|
}
|
||||||
|
if probe.stat == nil {
|
||||||
|
probe.stat = defaults.stat
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
candidate, ok, err := iterator.next()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errUnixSystemPresetUnavailable) {
|
||||||
|
return Source{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Unix system directory search is unavailable on this platform")}
|
||||||
|
}
|
||||||
|
return Source{}, []Diagnostic{classifySourceError(err, "")}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if required {
|
||||||
|
return Source{}, []Diagnostic{sourceDiagnostic(CodeSourceMissing, "", "required configuration search found no file")}
|
||||||
|
}
|
||||||
|
return NoSource(), nil
|
||||||
|
}
|
||||||
|
canonical, err := probe.evalSymlinks(candidate)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return File(candidate), []Diagnostic{classifySourceError(err, "")}
|
||||||
|
}
|
||||||
|
if _, err := probe.stat(canonical); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failed := File(candidate)
|
||||||
|
failed.path = canonical
|
||||||
|
return failed, []Diagnostic{classifySourceError(err, "")}
|
||||||
|
}
|
||||||
|
return File(candidate), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
430
internal/engine/discovery_load_test.go
Normal file
430
internal/engine/discovery_load_test.go
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type discoveryLoadConfig struct {
|
||||||
|
Service struct {
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
} `yaml:"service"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchLoadMatchesExplicitFileThroughImportsAndContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, root, "app.yml", `configkit:
|
||||||
|
imports: [{path: fragments/extra.yml}]
|
||||||
|
settings: {main: selected}
|
||||||
|
`)
|
||||||
|
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
|
||||||
|
search := SearchConfig("app", SearchDirectory(root))
|
||||||
|
options := Options[discoveryLoadConfig]{
|
||||||
|
Imports: true, Filesystem: &FilesystemContext{Root: root}, Destination: Keys("service"),
|
||||||
|
}
|
||||||
|
searched, err := testLoad(search, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := testLoad(File(mainPath), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
||||||
|
Keys("service", "settings", "main"), Keys("service", "settings", "imported"))
|
||||||
|
|
||||||
|
sources := searched.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Kind != SourceKindFile || sources[0].Path != canonicalTestPath(t, mainPath) || sources[1].Path != canonicalTestPath(t, importPath) {
|
||||||
|
t.Fatalf("searched source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchLayeredMainAndUnderlayMatchExplicitFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||||
|
underlayPath := writeImportFile(t, root, "underlay/base.yml", `configkit:
|
||||||
|
imports: [{path: extra.yml}]
|
||||||
|
settings: {underlay: value}
|
||||||
|
`)
|
||||||
|
importPath := writeImportFile(t, root, "underlay/extra.yml", "settings: {imported: value}\n")
|
||||||
|
mainSearch := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("main.yml")}}))
|
||||||
|
underlaySearch := SearchConfig("", SearchDirectory(filepath.Join(root, "underlay"), DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("base.yml")}}))
|
||||||
|
load := func(main, underlay Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(
|
||||||
|
Input{Source: main, Imports: true}, testSecretBinding(),
|
||||||
|
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: underlay, Imports: true}}}}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searched, err := load(mainSearch, underlaySearch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := load(File(mainPath), File(underlayPath))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
||||||
|
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
|
||||||
|
|
||||||
|
sources := searched.Inspection.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
|
||||||
|
t.Fatalf("layered search source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionalSearchExhaustionMatchesNoSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
defaults := func() discoveryLoadConfig {
|
||||||
|
return discoveryLoadConfig{Value: "default"}
|
||||||
|
}
|
||||||
|
options := Options[discoveryLoadConfig]{
|
||||||
|
Definition: Definition[discoveryLoadConfig]{Defaults: defaults},
|
||||||
|
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||||
|
}
|
||||||
|
searched, err := testLoad(SearchConfig("", Paths(missing)), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := testLoad(NoSource(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
||||||
|
if len(searched.Sources()) != 0 {
|
||||||
|
t.Fatalf("optional exhausted search created sources: %#v", searched.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSelectedFileFailuresDoNotFallBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("malformed YAML", func(t *testing.T) {
|
||||||
|
first := writeImportFile(t, t.TempDir(), "first.yml", ": malformed\n")
|
||||||
|
writeImportFile(t, filepath.Dir(first), "later.yml", "value: valid\n")
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(filepath.Dir(first), "later.yml"))), Options[discoveryLoadConfig]{})
|
||||||
|
if loaded != nil || err == nil {
|
||||||
|
t.Fatalf("malformed selected source = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("version check", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "version: rejected\n")
|
||||||
|
writeImportFile(t, root, "later.yml", "version: accepted\n")
|
||||||
|
var checked []string
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[discoveryLoadConfig]{
|
||||||
|
SourceVersionMode: sourceVersionModeIfPresent,
|
||||||
|
Definition: Definition[discoveryLoadConfig]{Version: &VersionRule{
|
||||||
|
Path: Keys("version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = append(checked, version.Text)
|
||||||
|
if version.Text == "rejected" {
|
||||||
|
return errors.New("version rejected")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if loaded != nil || err == nil || !reflect.DeepEqual(checked, []string{"rejected"}) {
|
||||||
|
t.Fatalf("version failure search = %#v, %v; checked %#v", loaded, err, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decode", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: not-an-integer\n")
|
||||||
|
writeImportFile(t, root, "later.yml", "value: 3\n")
|
||||||
|
type config struct {
|
||||||
|
Value int `yaml:"value"`
|
||||||
|
}
|
||||||
|
loaded, err := testLoad[config](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[config]{})
|
||||||
|
if loaded != nil || err == nil {
|
||||||
|
t.Fatalf("decode failure search = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
main := writeImportFile(t, root, "main.yml", "value: main\n")
|
||||||
|
underlay := writeImportFile(t, root, "underlay.yml", "settings: {first: underlay}\n")
|
||||||
|
bad := filepath.Join(main, "child.yml")
|
||||||
|
later := writeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||||
|
}{
|
||||||
|
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Destination: Keys("settings")})
|
||||||
|
}},
|
||||||
|
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{
|
||||||
|
{Source: File(underlay)}, {Source: source, Destination: Keys("service", "settings")},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
_, err := entry.load(File(bad))
|
||||||
|
var explicit *LoadError
|
||||||
|
if !errors.As(err, &explicit) || len(explicit.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("explicit failed file = %v", err)
|
||||||
|
}
|
||||||
|
if explicit.Diagnostics()[0].Code == CodeSourceMissing {
|
||||||
|
t.Skip("host classifies a non-directory ancestor as absence; controlled probe tests cover access failures")
|
||||||
|
}
|
||||||
|
result, err := entry.load(SearchConfig("", Paths(missing, bad, later)))
|
||||||
|
var searched *LoadError
|
||||||
|
if result != nil || !errors.As(err, &searched) {
|
||||||
|
t.Fatalf("failed search = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(searched.Sources(), explicit.Sources()) || !reflect.DeepEqual(searched.Diagnostics(), explicit.Diagnostics()) {
|
||||||
|
t.Fatalf("search failure differs from explicit file:\nsources %#v / %#v\ndiagnostics %#v / %#v", searched.Sources(), explicit.Sources(), searched.Diagnostics(), explicit.Diagnostics())
|
||||||
|
}
|
||||||
|
primary := searched.Diagnostics()[0].Primary
|
||||||
|
if primary == nil || primary.SourceID != entry.id {
|
||||||
|
t.Fatalf("failed candidate location = %#v, want %s", primary, entry.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchValidationPrecedesCandidateProbing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: selected\n")
|
||||||
|
_, err := testLoad[discoveryLoadConfig](SearchConfig("", Searches(Paths(first), Paths(""))), Options[discoveryLoadConfig]{})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid later search error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("invalid later search component diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
malformed := writeImportFile(t, t.TempDir(), "main.yml", ": malformed\n")
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: SearchConfig("", Paths(malformed))}, testSecretBinding(),
|
||||||
|
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: SearchConfig("", Paths(""))}}}}},
|
||||||
|
)
|
||||||
|
if loaded != nil || !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid later underlay search = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("layered validation acquired main before rejecting underlay: %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
||||||
|
writeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
search := SearchConfig("", Paths(first, filepath.Join(root, "later.yml")))
|
||||||
|
options := Options[discoveryLoadConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}}
|
||||||
|
_, err := testLoad[discoveryLoadConfig](search, options)
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("selected file with explicit base error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("selected file context diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchEmptyContextUsesSelectedOutcome(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selected := writeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
context := &FilesystemContext{}
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name string
|
||||||
|
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||||
|
}{
|
||||||
|
{"ordinary", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Imports: true, Filesystem: context})
|
||||||
|
}},
|
||||||
|
{"layered main", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(Input{Source: source, Imports: true, Filesystem: context}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{})
|
||||||
|
}},
|
||||||
|
{"underlay", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: source, Imports: true, Filesystem: context}}}},
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
explicit, err := entry.load(File(selected))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
searched, err := entry.load(SearchConfig("", Paths(missing, selected)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
||||||
|
if searched.Config.Value != "selected" {
|
||||||
|
t.Fatalf("selected value = %q", searched.Config.Value)
|
||||||
|
}
|
||||||
|
for _, source := range []Source{NoSource(), SearchConfig("", Paths(missing))} {
|
||||||
|
result, err := entry.load(source)
|
||||||
|
var failure *LoadError
|
||||||
|
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("absent input with empty context = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
file := writeImportFile(t, root, "config.yml", "value: first\n")
|
||||||
|
source := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("config.yml")}}))
|
||||||
|
load := func() string {
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loaded.Config.Value
|
||||||
|
}
|
||||||
|
if value := load(); value != "first" {
|
||||||
|
t.Fatalf("first search value = %q", value)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte("value: second\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value := load(); value != "second" {
|
||||||
|
t.Fatalf("updated search value = %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSourceObservesUserConfigEnvironmentChanges(t *testing.T) {
|
||||||
|
setBase := func(directory string) string {
|
||||||
|
t.Helper()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
t.Setenv("AppData", directory)
|
||||||
|
case "darwin", "ios":
|
||||||
|
t.Setenv("HOME", directory)
|
||||||
|
case "plan9":
|
||||||
|
t.Setenv("home", directory)
|
||||||
|
default:
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", directory)
|
||||||
|
}
|
||||||
|
base, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
firstBase := setBase(filepath.Join(t.TempDir(), "first"))
|
||||||
|
first := writeImportFile(t, firstBase, "app/app.yml", "value: first\n")
|
||||||
|
source := SearchConfig("app", UserConfigDirectory())
|
||||||
|
load := func() string {
|
||||||
|
loaded, err := testLoad[config](source, Options[config]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loaded.Config.Value
|
||||||
|
}
|
||||||
|
if value := load(); value != "first" {
|
||||||
|
t.Fatalf("first user-config search value = %q (%s)", value, first)
|
||||||
|
}
|
||||||
|
secondBase := setBase(filepath.Join(t.TempDir(), "second"))
|
||||||
|
second := writeImportFile(t, secondBase, "app/app.yml", "value: second\n")
|
||||||
|
if value := load(); value != "second" {
|
||||||
|
t.Fatalf("updated user-config search value = %q (%s)", value, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSourceObservesWorkingDirectoryChanges(t *testing.T) {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chdir(original); err != nil {
|
||||||
|
t.Errorf("restore working directory: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
firstDir := t.TempDir()
|
||||||
|
secondDir := t.TempDir()
|
||||||
|
writeImportFile(t, firstDir, "app.yml", "value: first\n")
|
||||||
|
writeImportFile(t, secondDir, "app.yml", "value: second\n")
|
||||||
|
source := SearchConfig("app", WorkingDirectory())
|
||||||
|
load := func() string {
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loaded.Config.Value
|
||||||
|
}
|
||||||
|
if err := os.Chdir(firstDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value := load(); value != "first" {
|
||||||
|
t.Fatalf("first working-directory search value = %q", value)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(secondDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value := load(); value != "second" {
|
||||||
|
t.Fatalf("updated working-directory search value = %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEquivalentDiscoveryLoads[T any](t *testing.T, leftConfig T, left *Inspection, rightConfig T, right *Inspection, presencePaths ...Path) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(leftConfig, rightConfig) {
|
||||||
|
t.Fatalf("search config %#v differs from explicit file config %#v", leftConfig, rightConfig)
|
||||||
|
}
|
||||||
|
leftSources, rightSources := left.Sources(), right.Sources()
|
||||||
|
if !reflect.DeepEqual(leftSources, rightSources) {
|
||||||
|
t.Fatalf("search sources %#v differ from explicit file sources %#v", leftSources, rightSources)
|
||||||
|
}
|
||||||
|
leftMetadata, rightMetadata := left.Metadata(), right.Metadata()
|
||||||
|
for _, path := range presencePaths {
|
||||||
|
leftInfo, leftPresent := leftMetadata.Lookup(path)
|
||||||
|
rightInfo, rightPresent := rightMetadata.Lookup(path)
|
||||||
|
if leftPresent != rightPresent || !reflect.DeepEqual(leftInfo, rightInfo) {
|
||||||
|
t.Fatalf("search metadata at %s = %#v, %v; explicit metadata = %#v, %v", path.String(), leftInfo, leftPresent, rightInfo, rightPresent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range leftSources {
|
||||||
|
leftContext, leftOK := left.SourceContext(source.ID)
|
||||||
|
rightContext, rightOK := right.SourceContext(source.ID)
|
||||||
|
if leftOK != rightOK || leftContext.BaseDir != rightContext.BaseDir || (leftContext.FS != nil) != (rightContext.FS != nil) {
|
||||||
|
t.Fatalf("search context for %s = %#v, %v; explicit context = %#v, %v", source.ID, leftContext, leftOK, rightContext, rightOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/engine/discovery_platform_other.go
Normal file
5
internal/engine/discovery_platform_other.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !hurd && !illumos && !ios && !linux && !netbsd && !openbsd && !solaris
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
func unixSystemPresetSupported() bool { return false }
|
||||||
5
internal/engine/discovery_platform_unix.go
Normal file
5
internal/engine/discovery_platform_unix.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build aix || android || darwin || dragonfly || freebsd || hurd || illumos || ios || linux || netbsd || openbsd || solaris
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
func unixSystemPresetSupported() bool { return true }
|
||||||
519
internal/engine/discovery_test.go
Normal file
519
internal/engine/discovery_test.go
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("zero search diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("empty concatenation diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("empty path list diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}})
|
||||||
|
if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("valid preset slug diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchDescriptionSnapshotsInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
paths := []string{"first.yml", "second.yml"}
|
||||||
|
pathSearch := Paths(paths...)
|
||||||
|
paths[0] = "changed.yml"
|
||||||
|
|
||||||
|
customNames := []string{"one.yml", "two.yml"}
|
||||||
|
group := CustomFilenames(customNames...)
|
||||||
|
customNames[0] = "changed.yml"
|
||||||
|
groups := []FilenameGroup{group}
|
||||||
|
options := DirectorySearchOptions{Filenames: groups}
|
||||||
|
directorySearch := SearchDirectory("configs", options)
|
||||||
|
groups[0] = GenericFilenames()
|
||||||
|
options.Filenames[0] = NamedFilenames()
|
||||||
|
|
||||||
|
searches := []Search{pathSearch, directorySearch}
|
||||||
|
combined := Searches(searches...)
|
||||||
|
searches[0] = Search{}
|
||||||
|
|
||||||
|
if got := combined.entries[0].path; got != "first.yml" {
|
||||||
|
t.Fatalf("snapshotted path = %q", got)
|
||||||
|
}
|
||||||
|
gotNames := combined.entries[2].filenames[0].names
|
||||||
|
if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" {
|
||||||
|
t.Fatalf("snapshotted custom names = %#v", gotNames)
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("snapshot description diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
omitted := SearchDirectory("configs")
|
||||||
|
if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric {
|
||||||
|
t.Fatalf("omitted groups = %#v", omitted.entries)
|
||||||
|
}
|
||||||
|
explicitNil := SearchDirectory("configs", DirectorySearchOptions{})
|
||||||
|
if len(explicitNil.entries[0].filenames) != 2 {
|
||||||
|
t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames)
|
||||||
|
}
|
||||||
|
explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||||
|
if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 {
|
||||||
|
t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames)
|
||||||
|
}
|
||||||
|
if len(validateSearch(explicitEmpty, "")) != 0 {
|
||||||
|
t.Fatal("explicit empty filename selection should need no slug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{})
|
||||||
|
if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 {
|
||||||
|
t.Fatal("multiple option values should be invalid")
|
||||||
|
}
|
||||||
|
invalid := Paths("")
|
||||||
|
combined := Searches(Paths("good.yml"), invalid)
|
||||||
|
if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 {
|
||||||
|
t.Fatal("concatenation lost a constructor error")
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 {
|
||||||
|
t.Fatal("later invalid path was hidden by an earlier component")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")}
|
||||||
|
search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups})
|
||||||
|
got := search.entries[0].filenames
|
||||||
|
if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" {
|
||||||
|
t.Fatalf("filename group order = %#v", got)
|
||||||
|
}
|
||||||
|
if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 {
|
||||||
|
t.Fatalf("empty custom group = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameComponentValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} {
|
||||||
|
if validFilenameComponent(value) {
|
||||||
|
t.Errorf("validFilenameComponent(%q) = true", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for _, value := range []string{"CON", "CONIN$", "CONOUT$", "name.", "bad:name", "bad\x01name"} {
|
||||||
|
if validFilenameComponent(value) {
|
||||||
|
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Device names with extensions depend on the Windows version. Match
|
||||||
|
// Go's platform-aware decision instead of imposing a blanket ban.
|
||||||
|
for _, value := range []string{"nul.txt", "COM¹.log", "LPT³.txt", "CON.extra.yml"} {
|
||||||
|
if got, want := validFilenameComponent(value), filepath.IsLocal(value); got != want {
|
||||||
|
t.Errorf("Windows component %q validity = %v, want Go's %v", value, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, value := range []string{"CONIN$", "CONOUT$", "nul.txt"} {
|
||||||
|
if !validFilenameComponent(value) {
|
||||||
|
t.Errorf("Windows component restriction applied to this host: %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{"name with spaces", "naïve"} {
|
||||||
|
if !validFilenameComponent(value) {
|
||||||
|
t.Errorf("host-valid component %q rejected", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 {
|
||||||
|
t.Fatal("invalid custom filename was not retained for validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCandidatesPreserveDirectoryAndGroupOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||||
|
search := Searches(
|
||||||
|
Paths("first.yml"),
|
||||||
|
SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{
|
||||||
|
GenericFilenames(), CustomFilenames("special.yml"),
|
||||||
|
}}),
|
||||||
|
Paths("last.yml"),
|
||||||
|
)
|
||||||
|
candidates := collectSearchCandidates(t, search, "app", helpers)
|
||||||
|
want := []string{
|
||||||
|
filepath.Join(root, "first.yml"),
|
||||||
|
filepath.Join(root, "settings", "config.yml"),
|
||||||
|
filepath.Join(root, "settings", "config.yaml"),
|
||||||
|
filepath.Join(root, "settings", "main.yml"),
|
||||||
|
filepath.Join(root, "settings", "main.yaml"),
|
||||||
|
filepath.Join(root, "settings", "special.yml"),
|
||||||
|
filepath.Join(root, "last.yml"),
|
||||||
|
}
|
||||||
|
assertCandidatePaths(t, candidates, want)
|
||||||
|
|
||||||
|
reversed := SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{NamedFilenames(), GenericFilenames()}})
|
||||||
|
reversedCandidates := collectSearchCandidates(t, reversed, "app", helpers)
|
||||||
|
assertCandidatePaths(t, reversedCandidates[:2], []string{
|
||||||
|
filepath.Join(root, "settings", "app.yml"),
|
||||||
|
filepath.Join(root, "settings", "app.yaml"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPresetCandidateLists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
helpers := discoveryDirectoryHelpers{
|
||||||
|
userConfigDir: func() (string, error) { return "/controlled/config", nil },
|
||||||
|
userHomeDir: func() (string, error) { return "/controlled/home", nil },
|
||||||
|
workingDir: func() (string, error) { return "/controlled/work", nil },
|
||||||
|
unixSupported: func() bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
unix := collectSearchCandidates(t, UnixSystemDirectories(), "app", helpers)
|
||||||
|
if len(unix) != 14 {
|
||||||
|
t.Fatalf("Unix preset candidate count = %d: %#v", len(unix), unix)
|
||||||
|
}
|
||||||
|
assertCandidatePaths(t, unix, []string{
|
||||||
|
"/usr/local/etc/app/app.yml", "/usr/local/etc/app/app.yaml", "/usr/local/etc/app/config.yml", "/usr/local/etc/app/config.yaml", "/usr/local/etc/app/main.yml", "/usr/local/etc/app/main.yaml",
|
||||||
|
"/etc/app/app.yml", "/etc/app/app.yaml", "/etc/app/config.yml", "/etc/app/config.yaml", "/etc/app/main.yml", "/etc/app/main.yaml",
|
||||||
|
"/etc/app.yml", "/etc/app.yaml",
|
||||||
|
})
|
||||||
|
|
||||||
|
user := collectSearchCandidates(t, UserConfigDirectory(), "app", helpers)
|
||||||
|
assertCandidatePaths(t, user, []string{
|
||||||
|
"/controlled/config/app/app.yml", "/controlled/config/app/app.yaml", "/controlled/config/app/config.yml", "/controlled/config/app/config.yaml", "/controlled/config/app/main.yml", "/controlled/config/app/main.yaml",
|
||||||
|
"/controlled/config/app.yml", "/controlled/config/app.yaml",
|
||||||
|
})
|
||||||
|
home := collectSearchCandidates(t, HomeDirectory(), "app", helpers)
|
||||||
|
assertCandidatePaths(t, home, []string{
|
||||||
|
"/controlled/home/.app/app.yml", "/controlled/home/.app/app.yaml", "/controlled/home/.app/config.yml", "/controlled/home/.app/config.yaml", "/controlled/home/.app/main.yml", "/controlled/home/.app/main.yaml",
|
||||||
|
"/controlled/home/app.yml", "/controlled/home/app.yaml",
|
||||||
|
})
|
||||||
|
working := collectSearchCandidates(t, WorkingDirectory(), "app", helpers)
|
||||||
|
assertCandidatePaths(t, working, []string{"/controlled/work/app.yml", "/controlled/work/app.yaml"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCandidateExpansionIsLazyAndSkipsEmptyDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configCalls := 0
|
||||||
|
helpers := discoveryDirectoryHelpers{
|
||||||
|
userConfigDir: func() (string, error) {
|
||||||
|
configCalls++
|
||||||
|
return "/controlled/config", nil
|
||||||
|
},
|
||||||
|
workingDir: func() (string, error) { return "/controlled/work", nil },
|
||||||
|
unixSupported: func() bool { return true },
|
||||||
|
}
|
||||||
|
search := Searches(Paths("/selected.yml"), UserConfigDirectory())
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(search, "app", helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("iterator diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/selected.yml" {
|
||||||
|
t.Fatalf("first candidate = %q, %v, %v", candidate, ok, err)
|
||||||
|
}
|
||||||
|
if configCalls != 0 {
|
||||||
|
t.Fatalf("later user directory resolved before consumption: %d calls", configCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyOnly := SearchDirectory("should-not-resolve", DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||||
|
emptyCandidates := collectSearchCandidates(t, emptyOnly, "", helpers)
|
||||||
|
if len(emptyCandidates) != 0 || configCalls != 0 {
|
||||||
|
t.Fatalf("empty directory candidates = %#v, helper calls = %d", emptyCandidates, configCalls)
|
||||||
|
}
|
||||||
|
continued := collectSearchCandidates(t, Searches(emptyOnly, Paths("later.yml")), "", helpers)
|
||||||
|
assertCandidatePaths(t, continued, []string{filepath.Join("/controlled/work", "later.yml")})
|
||||||
|
if configCalls != 0 {
|
||||||
|
t.Fatalf("empty directory base was resolved: %d calls", configCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCandidateBaseFailuresAndPlatformApplicability(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
baseFailure := errors.New("controlled base failure")
|
||||||
|
helpers := discoveryDirectoryHelpers{
|
||||||
|
userConfigDir: func() (string, error) { return "", baseFailure },
|
||||||
|
workingDir: func() (string, error) { return "/controlled/work", nil },
|
||||||
|
unixSupported: func() bool { return false },
|
||||||
|
}
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(UserConfigDirectory(), "app", helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("user preset validation = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if _, ok, err := iterator.next(); ok || !errors.Is(err, baseFailure) {
|
||||||
|
t.Fatalf("unavailable user base = ok %v, err %v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator, diagnostics = newSearchCandidateIterator(UnixSystemDirectories(), "app", helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("Unix preset validation = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if _, ok, err := iterator.next(); ok || !errors.Is(err, errUnixSystemPresetUnavailable) {
|
||||||
|
t.Fatalf("unsupported Unix preset = ok %v, err %v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator, diagnostics = newSearchCandidateIterator(Searches(Paths("/earlier.yml"), UnixSystemDirectories()), "app", helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("composed Unix preset validation = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/earlier.yml" {
|
||||||
|
t.Fatalf("earlier candidate = %q, %v, %v", candidate, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeAndShellLikeSearchPathsRemainLiteral(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||||
|
search := Searches(Paths("~/literal.yml", "$CONFIG/*.yaml"), SearchDirectory("./settings", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("name with spaces.yml")}}))
|
||||||
|
candidates := collectSearchCandidates(t, search, "", helpers)
|
||||||
|
assertCandidatePaths(t, candidates, []string{
|
||||||
|
filepath.Join(root, "~/literal.yml"),
|
||||||
|
filepath.Join(root, "$CONFIG/*.yaml"),
|
||||||
|
filepath.Join(root, "settings", "name with spaces.yml"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectSearchCandidates(t *testing.T, search Search, application string, helpers discoveryDirectoryHelpers) []string {
|
||||||
|
t.Helper()
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(search, application, helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("search validation = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
var candidates []string
|
||||||
|
for {
|
||||||
|
candidate, ok, err := iterator.next()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expand candidate: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCandidatePaths(t *testing.T, got, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("candidate count = %d, want %d: %#v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("candidate %d = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchSelectsFirstExistingCandidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := filepath.Join(root, "first.yml")
|
||||||
|
second := filepath.Join(root, "second.yml")
|
||||||
|
writeDiscoveryFile(t, first, "value: first\n")
|
||||||
|
writeDiscoveryFile(t, second, "value: second\n")
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(filepath.Join(root, "missing.yml"), first, second)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.kind != sourceFile || selected.path != first {
|
||||||
|
t.Fatalf("selection = %#v, diagnostics %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 0 || string(read.data) != "value: first\n" || read.info.Path != canonicalTestPath(t, first) {
|
||||||
|
t.Fatalf("selected source read = %#v, diagnostics %#v", read, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchNamedExtensionsAndNonregularSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, "app.yml"), "value: yml\n")
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, "app.yaml"), "value: yaml\n")
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(SearchDirectory(root)), "app", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != filepath.Join(root, "app.yml") {
|
||||||
|
t.Fatalf("named extension selection = %#v, %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
directory := filepath.Join(root, "subdirectory")
|
||||||
|
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
good := filepath.Join(root, "good.yml")
|
||||||
|
writeDiscoveryFile(t, good, "value: later\n")
|
||||||
|
selected, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(directory, good)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != directory {
|
||||||
|
t.Fatalf("directory candidate selection = %#v, %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
|
||||||
|
t.Fatalf("selected directory read diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchContainedSymlinkAndNoFallbackAfterReadFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "actual.yml")
|
||||||
|
link := filepath.Join(root, "linked.yml")
|
||||||
|
writeDiscoveryFile(t, target, "value: linked\n")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, "later.yml"), "value: later\n")
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(link, filepath.Join(root, "later.yml"))), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != link {
|
||||||
|
t.Fatalf("symlink selection = %#v, %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 0 || string(read.data) != "value: linked\n" || read.info.Path != canonicalTestPath(t, target) {
|
||||||
|
t.Fatalf("symlink read = %#v, %#v", read, diagnostics)
|
||||||
|
}
|
||||||
|
if err := os.Remove(target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
|
||||||
|
t.Fatalf("selected source read failure = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchRequiredAndOptionalExhaustion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
optional, diagnostics := resolveSearchSource(Source{}, ptrSearch(Search{}), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || optional.kind != sourceNone {
|
||||||
|
t.Fatalf("optional empty search = %#v, %#v", optional, diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Search{}), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing || diagnostics[0].Primary != nil || !strings.Contains(diagnostics[0].Message, "search") {
|
||||||
|
t.Fatalf("required empty search = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
optional, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || optional.kind != sourceNone {
|
||||||
|
t.Fatalf("optional missing search = %#v, %#v", optional, diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
|
||||||
|
t.Fatalf("required missing search = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchLeavesNonSearchSourcesUntouched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := Bytes("memory", []byte("value: unchanged\n"))
|
||||||
|
resolved, diagnostics := resolveSearchSource(source, nil, "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || resolved.kind != sourceBytes || string(resolved.data) != "value: unchanged\n" {
|
||||||
|
t.Fatalf("non-search source = %#v, diagnostics = %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchStopsOnReachedProbeError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := filepath.Join(root, "first.yml")
|
||||||
|
second := filepath.Join(root, "second.yml")
|
||||||
|
writeDiscoveryFile(t, first, "value: first\n")
|
||||||
|
writeDiscoveryFile(t, second, "value: second\n")
|
||||||
|
statCalls := 0
|
||||||
|
probe := discoveryHostProbe{stat: func(path string) (fs.FileInfo, error) {
|
||||||
|
statCalls++
|
||||||
|
return nil, fs.ErrPermission
|
||||||
|
}}
|
||||||
|
selected, info, diagnostics := resolveSearchInput(SearchConfig("", Paths(first, second)), "source-3", discoveryDirectoryHelpers{}, probe)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourcePermission || statCalls != 1 {
|
||||||
|
t.Fatalf("probe failure = %#v, stat calls = %d", diagnostics, statCalls)
|
||||||
|
}
|
||||||
|
if selected.kind != sourceFile || info == nil || info.Path != canonicalTestPath(t, first) || info.Label != first || info.Kind != SourceKindFile || info.ID != "source-3" {
|
||||||
|
t.Fatalf("failed candidate evidence = %#v, %#v", selected, info)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != info.ID {
|
||||||
|
t.Fatalf("failed candidate diagnostic does not identify its file: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchLeavesLaterDirectoryUnresolvedAfterSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := filepath.Join(root, "selected.yml")
|
||||||
|
writeDiscoveryFile(t, first, "value: selected\n")
|
||||||
|
baseCalls := 0
|
||||||
|
helpers := discoveryDirectoryHelpers{userConfigDir: func() (string, error) {
|
||||||
|
baseCalls++
|
||||||
|
return "", errors.New("later base should not be reached")
|
||||||
|
}}
|
||||||
|
search := ptrSearch(Searches(Paths(first), UserConfigDirectory()))
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, search, "app", false, helpers, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != first || baseCalls != 0 {
|
||||||
|
t.Fatalf("selected = %#v, diagnostics = %#v, later base calls = %d", selected, diagnostics, baseCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchTreatsShellLikeNamesLiterally(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := []string{"~/literal.yml", "$CONFIG/literal.yml", "*.yaml"}
|
||||||
|
for _, name := range paths {
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, name), "value: literal\n")
|
||||||
|
}
|
||||||
|
helper := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||||
|
for _, name := range paths {
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(name)), "", false, helper, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != filepath.Join(root, name) {
|
||||||
|
t.Fatalf("literal %q selection = %#v, %#v", name, selected, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrSearch(search Search) *Search { return &search }
|
||||||
|
|
||||||
|
func writeDiscoveryFile(t *testing.T, path, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
internal/engine/discovery_windows_test.go
Normal file
91
internal/engine/discovery_windows_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWindowsSearchRejectsReservedComponentsBeforeAcquisition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
||||||
|
for _, name := range []string{"CONIN$", "CONOUT$"} {
|
||||||
|
for _, source := range []Source{
|
||||||
|
SearchConfig(name, Searches(Paths(first), WorkingDirectory())),
|
||||||
|
SearchConfig("", Searches(Paths(first), SearchDirectory(root, DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames(name)},
|
||||||
|
}))),
|
||||||
|
} {
|
||||||
|
result, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||||
|
var failure *LoadError
|
||||||
|
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("reserved component %q accepted: %#v, %v", name, result, err)
|
||||||
|
}
|
||||||
|
if len(failure.Sources()) != 0 {
|
||||||
|
t.Fatalf("reserved component %q was checked after acquisition: %#v", name, failure.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsSearchPathsMatchHostAbsoluteResolution(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
file := writeImportFile(t, root, "settings/config.yml", "value: selected\n")
|
||||||
|
working := t.TempDir()
|
||||||
|
t.Chdir(working)
|
||||||
|
relative, err := filepath.Rel(working, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
volume := filepath.VolumeName(file)
|
||||||
|
rooted := strings.TrimPrefix(file, volume)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"relative", relative},
|
||||||
|
{"drive root relative", rooted},
|
||||||
|
{"slash root relative", filepath.ToSlash(rooted)},
|
||||||
|
{"drive relative", volume + relative},
|
||||||
|
{"absolute", file},
|
||||||
|
} {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
want, err := filepath.Abs(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := testLoad[discoveryLoadConfig](File(entry.path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, search := range []Search{
|
||||||
|
Paths(entry.path),
|
||||||
|
SearchDirectory(filepath.Dir(entry.path), DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames(filepath.Base(entry.path))},
|
||||||
|
}),
|
||||||
|
} {
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(search, "", discoveryDirectoryHelpers{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
candidate, ok, err := iterator.next()
|
||||||
|
if err != nil || !ok || candidate != want {
|
||||||
|
t.Fatalf("candidate = %q, %v, %v; want %q", candidate, ok, err, want)
|
||||||
|
}
|
||||||
|
result, err := testLoad[discoveryLoadConfig](SearchConfig("", search))
|
||||||
|
if err != nil || result.Config.Value != explicit.Config.Value {
|
||||||
|
t.Fatalf("searched path = %#v, %v; explicit value %q", result, err, explicit.Config.Value)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Path != explicit.Sources()[0].Path {
|
||||||
|
t.Fatalf("searched source = %#v; explicit source %#v", sources, explicit.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type unsupportedDynamicRoot struct {
|
||||||
|
Child unsupportedDynamicChild `yaml:"child"`
|
||||||
|
Invalid chan int `yaml:"invalid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unsupportedDynamicChild struct {
|
||||||
|
Parent *unsupportedDynamicRoot `yaml:"parent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretDynamicRoot struct {
|
||||||
|
Nested secretDynamicNested `yaml:"nested"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretDynamicNested struct {
|
||||||
|
Token Secret `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicTransactionConfig struct {
|
||||||
|
First any `yaml:"first"`
|
||||||
|
Second any `yaml:"second"`
|
||||||
|
Good any `yaml:"good"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDynamicDescriptorFailuresRollBackProvisionalTypes(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
result, err := testLoad[dynamicTransactionConfig](NoSource(), Options[dynamicTransactionConfig]{Definition: Definition[dynamicTransactionConfig]{
|
||||||
|
Resolve: func(config *dynamicTransactionConfig, resolution *Resolution) error {
|
||||||
|
called = true
|
||||||
|
Normalize(resolution, Keys("first"), &config.First, any((*unsupportedDynamicRoot)(nil)), "try typed nil")
|
||||||
|
Normalize(resolution, Keys("second"), &config.Second, any(&unsupportedDynamicRoot{}), "try non-nil")
|
||||||
|
Normalize(resolution, Keys("good"), &config.Good, any(map[string]string{"valid": "value"}), "continue with supported value")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !called || result != nil || !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("result = %#v, called=%v, error=%v", result, called, err)
|
||||||
|
}
|
||||||
|
paths := make(map[string]Code)
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
paths[diagnostic.Path.String()] = diagnostic.Code
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/first", "/second"} {
|
||||||
|
if paths[path] != CodeResolveInvalidValue {
|
||||||
|
t.Errorf("diagnostic at %s = %q, want safe invalid-value failure", path, paths[path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := mustSchemaFor[dynamicTransactionConfig](t)
|
||||||
|
operation := valueOperation{schema: schema}
|
||||||
|
prior, issue := operation.dynamicDescriptor(reflect.TypeFor[map[string]string](), Keys("prior"))
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatalf("prior valid dynamic descriptor issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("failure")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||||
|
t.Fatalf("unsupported dynamic descriptor issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if _, approved := operation.approvedDynamic[reflect.TypeFor[unsupportedDynamicRoot]()]; approved {
|
||||||
|
t.Fatal("unsupported dynamic descriptor was cached as eligible")
|
||||||
|
}
|
||||||
|
if schema.types[reflect.TypeFor[map[string]string]()] != prior {
|
||||||
|
t.Fatal("failed build removed a previously completed descriptor")
|
||||||
|
}
|
||||||
|
for _, current := range []reflect.Type{
|
||||||
|
reflect.TypeFor[unsupportedDynamicRoot](),
|
||||||
|
reflect.TypeFor[unsupportedDynamicChild](),
|
||||||
|
reflect.TypeFor[*unsupportedDynamicRoot](),
|
||||||
|
} {
|
||||||
|
if _, exists := schema.types[current]; exists {
|
||||||
|
t.Errorf("failed build retained provisional descriptor for %v", current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("again")); issue == nil || issue.path.String() != "/again" {
|
||||||
|
t.Fatalf("repeated failure issue = %#v", issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretSchema, schemaIssue := buildSchema(reflect.TypeFor[dynamicTransactionConfig](), testSecretBinding())
|
||||||
|
if schemaIssue != nil {
|
||||||
|
t.Fatalf("secret schema issue = %#v", schemaIssue)
|
||||||
|
}
|
||||||
|
secretOperation := valueOperation{schema: secretSchema}
|
||||||
|
if _, issue := secretOperation.dynamicDescriptor(reflect.TypeFor[secretDynamicRoot](), Keys("secret")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||||
|
t.Fatalf("dynamic secret eligibility issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if len(secretOperation.approvedDynamic) != 0 {
|
||||||
|
t.Fatalf("secret-bearing dynamic descriptor was cached as eligible: %#v", secretOperation.approvedDynamic)
|
||||||
|
}
|
||||||
|
for _, current := range []reflect.Type{reflect.TypeFor[secretDynamicRoot](), reflect.TypeFor[secretDynamicNested](), reflect.TypeFor[Secret]()} {
|
||||||
|
if _, exists := secretSchema.types[current]; exists {
|
||||||
|
t.Errorf("secret eligibility failure retained provisional descriptor for %v", current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type wideDynamicEligibilityValue struct {
|
||||||
|
Alpha string
|
||||||
|
Bravo string
|
||||||
|
Charlie string
|
||||||
|
Delta string
|
||||||
|
Echo string
|
||||||
|
Foxtrot string
|
||||||
|
Golf string
|
||||||
|
Hotel string
|
||||||
|
India string
|
||||||
|
Juliet string
|
||||||
|
Kilo string
|
||||||
|
Lima string
|
||||||
|
Mike string
|
||||||
|
November string
|
||||||
|
Oscar string
|
||||||
|
Papa string
|
||||||
|
}
|
||||||
|
|
||||||
|
type repeatedNilDynamicEligibilityConfig struct {
|
||||||
|
Values []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDynamicEligibilityIsReusedWithinProjectionOperation(t *testing.T) {
|
||||||
|
schema, schemaIssue := buildSchema(reflect.TypeFor[repeatedNilDynamicEligibilityConfig](), testSecretBinding())
|
||||||
|
if schemaIssue != nil {
|
||||||
|
t.Fatalf("schema issue = %#v", schemaIssue)
|
||||||
|
}
|
||||||
|
values := make([]any, 32)
|
||||||
|
for index := range values {
|
||||||
|
values[index] = (*wideDynamicEligibilityValue)(nil)
|
||||||
|
}
|
||||||
|
configuration := repeatedNilDynamicEligibilityConfig{Values: values}
|
||||||
|
operation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||||
|
node, issue := operation.project(reflect.ValueOf(configuration), schema.root, Path{}, 0)
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatalf("repeated typed-nil projection issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if len(node.Mapping) != 1 || len(node.Mapping[0].Value.Sequence) != len(values) {
|
||||||
|
t.Fatalf("projected repeated values = %#v", node)
|
||||||
|
}
|
||||||
|
for index, child := range node.Mapping[0].Value.Sequence {
|
||||||
|
if child.Kind != document.Null {
|
||||||
|
t.Errorf("projected value %d kind = %v, want null", index, child.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current := reflect.TypeFor[*wideDynamicEligibilityValue]()
|
||||||
|
if len(operation.approvedDynamic) != 1 || operation.approvedDynamic[current] == nil ||
|
||||||
|
operation.approvedDynamic[current] != schema.types[current] {
|
||||||
|
t.Fatalf("operation eligibility cache = %#v", operation.approvedDynamic)
|
||||||
|
}
|
||||||
|
otherOperation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||||
|
if len(otherOperation.approvedDynamic) != 0 {
|
||||||
|
t.Fatalf("dynamic eligibility leaked into another operation: %#v", otherOperation.approvedDynamic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,12 +308,18 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
|
|||||||
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
|
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
|
||||||
leaf := &document.Node{Kind: document.String, Text: "value"}
|
leaf := &document.Node{Kind: document.String, Text: "value"}
|
||||||
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
|
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
|
||||||
applications := []historyApplication{{layer: LayerBase, value: root}}
|
inspection := &Inspection{
|
||||||
if !historyNodesWithin(2, root, root, root, applications, nil) {
|
baseline: root, input: root, final: root,
|
||||||
|
applications: []historyApplication{{layer: LayerBase, value: root}},
|
||||||
|
}
|
||||||
|
counter := newRetainedNodeCounter(2)
|
||||||
|
if !addInspectionHistory(counter, inspection) || counter.count != 2 {
|
||||||
t.Fatal("structurally shared history was counted more than once")
|
t.Fatal("structurally shared history was counted more than once")
|
||||||
}
|
}
|
||||||
other := document.Clone(root)
|
other := document.Clone(root)
|
||||||
if historyNodesWithin(3, root, other, root, applications, nil) {
|
inspection.input = other
|
||||||
|
counter = newRetainedNodeCounter(3)
|
||||||
|
if addInspectionHistory(counter, inspection) {
|
||||||
t.Fatal("distinct retained nodes did not consume the budget")
|
t.Fatal("distinct retained nodes did not consume the budget")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,52 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
if nativeContext(source, context) {
|
resolved, diagnostic := resolveFilesystemContext(source, context, main, hostToken)
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return diagnostic
|
|
||||||
}
|
|
||||||
return validateNativeDirectories(access, basePath, mainPath)
|
|
||||||
}
|
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return diagnostic
|
// Keep absent-input validation's existing diagnostic while sharing
|
||||||
|
// root/base derivation with overlay and resource-context handling.
|
||||||
|
if source.kind == sourceNone && context.FS == nil && diagnostic.Code == CodeImportOutsideRoot {
|
||||||
|
diagnostic.Message = "import base must stay inside the configuration root"
|
||||||
|
}
|
||||||
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(rootPath)
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
|
}
|
||||||
|
if resolved.native != nil {
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(resolved.rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return &value
|
return resolvedFilesystemContext{}, &value
|
||||||
}
|
}
|
||||||
if err := root.Close(); err != nil {
|
if err := root.Close(); err != nil {
|
||||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
return &value
|
return resolvedFilesystemContext{}, &value
|
||||||
}
|
}
|
||||||
return nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
switch source.kind {
|
switch source.kind {
|
||||||
case sourceNone:
|
case sourceNone:
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
return absentFilesystemOptionDiagnostics(context)
|
||||||
|
case sourceSearch:
|
||||||
|
if context.FS != nil {
|
||||||
|
return absentFilesystemOptionDiagnostics(context)
|
||||||
|
}
|
||||||
|
// A selected host file can derive its root; absence requires an
|
||||||
|
// explicit root. Check that compatibility only after selection.
|
||||||
|
if context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem base directory must be an absolute host path")}
|
||||||
|
}
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
if context.FS != nil {
|
if context.FS != nil {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
||||||
@@ -69,115 +81,19 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nativeContext(source Source, context *FilesystemContext) bool {
|
func absentFilesystemOptionDiagnostics(context *FilesystemContext) []Diagnostic {
|
||||||
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
if context.FS != nil {
|
||||||
}
|
if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")}
|
||||||
// nativeFilesystemAccess resolves namespace context without opening caller
|
|
||||||
// resources. File inputs always derive their base from the selected file.
|
|
||||||
func nativeFilesystemAccess(source Source, context *FilesystemContext) (*filesystemAccess, string, string, *Diagnostic) {
|
|
||||||
var namespace *nativeNamespace
|
|
||||||
rootPath := "."
|
|
||||||
basePath := "."
|
|
||||||
mainPath := ""
|
|
||||||
if source.kind == sourceFilesystem {
|
|
||||||
namespace = source.namespace
|
|
||||||
mainPath = source.path
|
|
||||||
basePath = path.Dir(source.path)
|
|
||||||
if context != nil && context.Root != "" {
|
|
||||||
rootPath = context.Root
|
|
||||||
}
|
|
||||||
} else if context != nil && context.FS != nil {
|
|
||||||
namespace = context.FS.namespace
|
|
||||||
rootPath, basePath = context.Root, context.BaseDir
|
|
||||||
}
|
|
||||||
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(rootPath) || !fs.ValidPath(basePath) {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
|
|
||||||
return nil, "", "", &value
|
|
||||||
}
|
|
||||||
access := newNativeAccess(namespace.filesystem, rootPath, namespace.token)
|
|
||||||
access.label = namespace.label
|
|
||||||
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
|
|
||||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
|
||||||
return nil, "", "", &value
|
|
||||||
}
|
|
||||||
return access, basePath, mainPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateNativeDirectories(access *filesystemAccess, basePath, mainPath string) *Diagnostic {
|
|
||||||
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
|
|
||||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
for _, directory := range []string{access.rootPath, basePath} {
|
|
||||||
info, err := access.stat(directory)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
|
||||||
return &value
|
|
||||||
}
|
}
|
||||||
|
} else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
func nativeContext(source Source, context *FilesystemContext) bool {
|
||||||
if main.source == nil || main.document == nil {
|
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
||||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
|
|
||||||
if source.kind == sourceFile {
|
|
||||||
if context != nil && context.BaseDir != "" {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
canonical, err := canonicalExistingPath(source.path)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
mainPath = canonical
|
|
||||||
basePath = filepath.Dir(canonical)
|
|
||||||
rootPath = basePath
|
|
||||||
if context != nil && context.Root != "" {
|
|
||||||
rootPath, err = canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if context == nil || context.BaseDir == "" || context.Root == "" {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
|
||||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
rootPath, err = canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
|
|
||||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
for _, candidate := range []string{rootPath, basePath} {
|
|
||||||
info, err := os.Stat(candidate)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
|
||||||
return "", "", "", &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rootPath, basePath, mainPath, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalExistingPath(name string) (string, error) {
|
func canonicalExistingPath(name string) (string, error) {
|
||||||
|
|||||||
@@ -141,40 +141,59 @@ func (a *filesystemAccess) open(name string) (fs.File, error) {
|
|||||||
return a.root.Open(relative)
|
return a.root.Open(relative)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type directoryReadError struct {
|
||||||
|
opened bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *directoryReadError) Error() string { return "directory could not be read" }
|
||||||
|
func (e *directoryReadError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
func missingDirectoryOpen(err error) bool {
|
||||||
|
var failure *directoryReadError
|
||||||
|
return errors.As(err, &failure) && !failure.opened && errors.Is(err, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
|
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
|
||||||
file, err := a.open(name)
|
file, err := a.open(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return &directoryReadError{err: err}
|
||||||
|
}
|
||||||
|
failure := func(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &directoryReadError{opened: true, err: err}
|
||||||
}
|
}
|
||||||
if reader, ok := file.(fs.ReadDirFile); ok {
|
if reader, ok := file.(fs.ReadDirFile); ok {
|
||||||
for {
|
for {
|
||||||
entries, readErr := reader.ReadDir(globReadBatch)
|
entries, readErr := reader.ReadDir(globReadBatch)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !visit(entry) {
|
if !visit(entry) {
|
||||||
return file.Close()
|
return failure(file.Close())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errors.Is(readErr, io.EOF) {
|
if errors.Is(readErr, io.EOF) {
|
||||||
return file.Close()
|
return failure(file.Close())
|
||||||
}
|
}
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
return readErr
|
return failure(readErr)
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
return io.ErrNoProgress
|
return failure(io.ErrNoProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := file.Close(); err != nil {
|
if err := file.Close(); err != nil {
|
||||||
return err
|
return failure(err)
|
||||||
}
|
}
|
||||||
// fs.ReadDir provides the standard fallback for implementations whose opened
|
// fs.ReadDir provides the standard fallback for implementations whose opened
|
||||||
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
|
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
|
||||||
entries, err := fs.ReadDir(a.native, name)
|
entries, err := fs.ReadDir(a.native, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return failure(err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !visit(entry) {
|
if !visit(entry) {
|
||||||
|
|||||||
176
internal/engine/filesystem_context.go
Normal file
176
internal/engine/filesystem_context.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolvedFilesystemContext struct {
|
||||||
|
native *nativeNamespace
|
||||||
|
hostToken *namespaceToken
|
||||||
|
rootPath string
|
||||||
|
basePath string
|
||||||
|
mainPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
|
if source.kind == sourceNone && context != nil {
|
||||||
|
return resolveAbsentFilesystemContext(context)
|
||||||
|
}
|
||||||
|
if nativeContext(source, context) {
|
||||||
|
return resolveNativeFilesystemContext(source, context)
|
||||||
|
}
|
||||||
|
return resolveHostFilesystemContext(source, context, main, hostToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAbsentFilesystemContext(context *FilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
|
if context.FS != nil {
|
||||||
|
return resolveNativeFilesystemContext(Source{kind: sourceNone}, context)
|
||||||
|
}
|
||||||
|
rootPath, err := canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
basePath := rootPath
|
||||||
|
if context.BaseDir != "" {
|
||||||
|
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containedPath(rootPath, basePath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
return resolvedFilesystemContext{rootPath: rootPath, basePath: basePath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNativeFilesystemContext(source Source, context *FilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
|
var namespace *nativeNamespace
|
||||||
|
resolved := resolvedFilesystemContext{rootPath: ".", basePath: "."}
|
||||||
|
if source.kind == sourceFilesystem {
|
||||||
|
namespace = source.namespace
|
||||||
|
resolved.mainPath = source.path
|
||||||
|
resolved.basePath = path.Dir(source.path)
|
||||||
|
if context != nil && context.Root != "" {
|
||||||
|
resolved.rootPath = context.Root
|
||||||
|
}
|
||||||
|
} else if context != nil && context.FS != nil {
|
||||||
|
namespace = context.FS.namespace
|
||||||
|
resolved.rootPath, resolved.basePath = context.Root, context.BaseDir
|
||||||
|
}
|
||||||
|
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(resolved.rootPath) || !fs.ValidPath(resolved.basePath) {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
resolved.native = namespace
|
||||||
|
if !containsNativePath(resolved.rootPath, resolved.basePath) || resolved.mainPath != "" && !containsNativePath(resolved.rootPath, resolved.mainPath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHostFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
|
if main.source == nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
resolved := resolvedFilesystemContext{hostToken: hostToken}
|
||||||
|
if source.kind == sourceFile {
|
||||||
|
if context != nil && context.BaseDir != "" {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
canonical := main.source.Path
|
||||||
|
if canonical == "" {
|
||||||
|
var err error
|
||||||
|
canonical, err = canonicalExistingPath(source.path)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved.mainPath = canonical
|
||||||
|
resolved.basePath = filepath.Dir(canonical)
|
||||||
|
resolved.rootPath = resolved.basePath
|
||||||
|
if context != nil && context.Root != "" {
|
||||||
|
rootPath, err := canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
resolved.rootPath = rootPath
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if context == nil || context.BaseDir == "" || context.Root == "" {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
resolved.basePath, err = canonicalExistingPath(context.BaseDir)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
resolved.rootPath, err = canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containedPath(resolved.rootPath, resolved.basePath) || resolved.mainPath != "" && !containedPath(resolved.rootPath, resolved.mainPath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsNativePath(root, name string) bool {
|
||||||
|
if !fs.ValidPath(name) || !fs.ValidPath(root) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return root == "." || name == root || strings.HasPrefix(name, root+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResolvedFilesystemDirectories(resolved resolvedFilesystemContext) *Diagnostic {
|
||||||
|
for _, directory := range []string{resolved.rootPath, resolved.basePath} {
|
||||||
|
var info fs.FileInfo
|
||||||
|
var err error
|
||||||
|
if resolved.native != nil {
|
||||||
|
info, err = fs.Stat(resolved.native.filesystem, directory)
|
||||||
|
} else {
|
||||||
|
info, err = os.Stat(directory)
|
||||||
|
}
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openResolvedFilesystemAccess(resolved resolvedFilesystemContext) (*filesystemAccess, error) {
|
||||||
|
if resolved.native != nil {
|
||||||
|
access := newNativeAccess(resolved.native.filesystem, resolved.rootPath, resolved.native.token)
|
||||||
|
access.label = resolved.native.label
|
||||||
|
return access, nil
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(resolved.rootPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resolved.hostToken != nil {
|
||||||
|
access.token = resolved.hostToken
|
||||||
|
}
|
||||||
|
return access, nil
|
||||||
|
}
|
||||||
@@ -119,21 +119,34 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
|
|||||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// A link stays active until its target has resolved, not until the entire
|
// A link remains active until its own target resolves. Completed targets can be
|
||||||
// original pathname has resolved. Remaining segments may reuse a finished link.
|
// reused while the caller continues resolving its remaining components.
|
||||||
type importLinkFrame struct {
|
type importLinkFrame struct {
|
||||||
pending []string
|
pending []string
|
||||||
link string
|
link string
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
type importLinkOperations struct {
|
||||||
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}}
|
lstat func(string) (fs.FileInfo, error)
|
||||||
|
readlink func(string) (string, error)
|
||||||
|
normalizeTarget func(string, string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveImportLinkPath(name string, operations importLinkOperations) (string, error) {
|
||||||
|
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||||
resolved := ""
|
resolved := ""
|
||||||
activeLinks := make(map[string]struct{})
|
active := make(map[string]struct{})
|
||||||
|
completed := make(map[string]string)
|
||||||
for len(frames) > 0 {
|
for len(frames) > 0 {
|
||||||
frame := &frames[len(frames)-1]
|
frame := &frames[len(frames)-1]
|
||||||
if len(frame.pending) == 0 {
|
if len(frame.pending) == 0 {
|
||||||
delete(activeLinks, frame.link)
|
if frame.link != "" {
|
||||||
|
if resolved == "" {
|
||||||
|
resolved = "."
|
||||||
|
}
|
||||||
|
completed[frame.link] = resolved
|
||||||
|
delete(active, frame.link)
|
||||||
|
}
|
||||||
frames = frames[:len(frames)-1]
|
frames = frames[:len(frames)-1]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -143,7 +156,7 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
|||||||
if candidate == "" {
|
if candidate == "" {
|
||||||
candidate = "."
|
candidate = "."
|
||||||
}
|
}
|
||||||
info, err := access.root.Lstat(filepath.FromSlash(candidate))
|
info, err := operations.lstat(candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -151,27 +164,22 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
|||||||
resolved = candidate
|
resolved = candidate
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := activeLinks[candidate]; exists {
|
if target, ok := completed[candidate]; ok {
|
||||||
|
resolved = target
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := active[candidate]; exists {
|
||||||
return "", errImportDirectoryLinkCycle
|
return "", errImportDirectoryLinkCycle
|
||||||
}
|
}
|
||||||
activeLinks[candidate] = struct{}{}
|
target, err := operations.readlink(candidate)
|
||||||
target, err := access.root.Readlink(filepath.FromSlash(candidate))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var targetPath string
|
targetPath, err := operations.normalizeTarget(candidate, target)
|
||||||
if filepath.IsAbs(target) {
|
if err != nil {
|
||||||
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
|
return "", err
|
||||||
if err != nil {
|
|
||||||
return "", errImportPathOutsideRoot
|
|
||||||
}
|
|
||||||
targetPath = path.Clean(filepath.ToSlash(relative))
|
|
||||||
} else {
|
|
||||||
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
|
|
||||||
}
|
|
||||||
if !withinPortableRoot(targetPath) {
|
|
||||||
return "", errImportPathOutsideRoot
|
|
||||||
}
|
}
|
||||||
|
active[candidate] = struct{}{}
|
||||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||||
resolved = ""
|
resolved = ""
|
||||||
}
|
}
|
||||||
@@ -181,6 +189,37 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||||
|
return resolveImportLinkPath(filepath.ToSlash(name), hostImportLinkOperations(access))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostImportLinkOperations(access *filesystemAccess) importLinkOperations {
|
||||||
|
return importLinkOperations{
|
||||||
|
lstat: func(candidate string) (fs.FileInfo, error) {
|
||||||
|
return access.root.Lstat(filepath.FromSlash(candidate))
|
||||||
|
},
|
||||||
|
readlink: func(candidate string) (string, error) {
|
||||||
|
return access.root.Readlink(filepath.FromSlash(candidate))
|
||||||
|
},
|
||||||
|
normalizeTarget: func(candidate, target string) (string, error) {
|
||||||
|
var targetPath string
|
||||||
|
if filepath.IsAbs(target) {
|
||||||
|
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
|
||||||
|
if err != nil {
|
||||||
|
return "", errImportPathOutsideRoot
|
||||||
|
}
|
||||||
|
targetPath = path.Clean(filepath.ToSlash(relative))
|
||||||
|
} else {
|
||||||
|
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
|
||||||
|
}
|
||||||
|
if !withinPortableRoot(targetPath) {
|
||||||
|
return "", errImportPathOutsideRoot
|
||||||
|
}
|
||||||
|
return targetPath, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func withinPortableRoot(name string) bool {
|
func withinPortableRoot(name string) bool {
|
||||||
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
|
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
|
||||||
}
|
}
|
||||||
@@ -189,57 +228,27 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
|
|||||||
if !fs.ValidPath(name) || !access.contains(name) {
|
if !fs.ValidPath(name) || !access.contains(name) {
|
||||||
return "", fs.ErrInvalid
|
return "", fs.ErrInvalid
|
||||||
}
|
}
|
||||||
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
return resolveImportLinkPath(name, importLinkOperations{
|
||||||
resolved := ""
|
lstat: func(candidate string) (fs.FileInfo, error) {
|
||||||
activeLinks := make(map[string]struct{})
|
// Ancestors of a multi-component root are resolution prefixes, not
|
||||||
for len(frames) > 0 {
|
// selected targets. Complete paths and link targets remain contained.
|
||||||
frame := &frames[len(frames)-1]
|
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
|
||||||
if len(frame.pending) == 0 {
|
return nil, errImportPathOutsideRoot
|
||||||
delete(activeLinks, frame.link)
|
}
|
||||||
frames = frames[:len(frames)-1]
|
return filesystem.Lstat(candidate)
|
||||||
continue
|
},
|
||||||
}
|
readlink: filesystem.ReadLink,
|
||||||
segment := frame.pending[0]
|
normalizeTarget: func(candidate, target string) (string, error) {
|
||||||
frame.pending = frame.pending[1:]
|
if target == "" || strings.HasPrefix(target, "/") {
|
||||||
candidate := path.Join(resolved, segment)
|
return "", errImportPathOutsideRoot
|
||||||
if candidate == "" {
|
}
|
||||||
candidate = "."
|
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
|
||||||
}
|
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||||
// Ancestors of a multi-component root are resolution prefixes, not
|
return "", errImportPathOutsideRoot
|
||||||
// selected targets. Complete paths and link targets remain contained.
|
}
|
||||||
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
|
return targetPath, nil
|
||||||
return "", errImportPathOutsideRoot
|
},
|
||||||
}
|
})
|
||||||
info, err := filesystem.Lstat(candidate)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if info.Mode()&fs.ModeSymlink == 0 {
|
|
||||||
resolved = candidate
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := activeLinks[candidate]; exists {
|
|
||||||
return "", errImportDirectoryLinkCycle
|
|
||||||
}
|
|
||||||
activeLinks[candidate] = struct{}{}
|
|
||||||
target, err := filesystem.ReadLink(candidate)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if target == "" || strings.HasPrefix(target, "/") {
|
|
||||||
return "", errImportPathOutsideRoot
|
|
||||||
}
|
|
||||||
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
|
|
||||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
|
||||||
return "", errImportPathOutsideRoot
|
|
||||||
}
|
|
||||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
|
||||||
resolved = ""
|
|
||||||
}
|
|
||||||
if resolved == "" {
|
|
||||||
resolved = "."
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isImportLinkCycle(err error) bool {
|
func isImportLinkCycle(err error) bool {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -45,3 +46,270 @@ func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNativeLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
||||||
|
// The small graph stays within host symlink-depth limits while exceeding the
|
||||||
|
// bounded linear operation allowance without completed-target reuse.
|
||||||
|
const linkCount = 5
|
||||||
|
root := t.TempDir()
|
||||||
|
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||||
|
for _, directory := range []string{
|
||||||
|
filepath.Join(configurationRoot, "graph"),
|
||||||
|
filepath.Join(configurationRoot, "selected"),
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := 0; index < linkCount; index++ {
|
||||||
|
name := fmt.Sprintf("s%02d", index)
|
||||||
|
target := "."
|
||||||
|
if index > 0 {
|
||||||
|
previous := fmt.Sprintf("s%02d", index-1)
|
||||||
|
target = previous + "/" + previous
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(configurationRoot, "graph", name)); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configurationRoot, "graph", "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(fmt.Sprintf("../graph/s%02d", linkCount-1), filepath.Join(configurationRoot, "selected", "entry")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, selector := range []string{"directory", "glob"} {
|
||||||
|
t.Run(selector, func(t *testing.T) {
|
||||||
|
base := os.DirFS(root)
|
||||||
|
if _, ok := base.(fs.ReadLinkFS); !ok {
|
||||||
|
t.Skip("host directory filesystem does not expose link inspection")
|
||||||
|
}
|
||||||
|
filesystem := newCountingReadLinkFS(base, linkCount*20+40)
|
||||||
|
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
||||||
|
if _, err := access.canonical("workspace/config/selected"); err != nil {
|
||||||
|
t.Fatalf("selected directory canonicalization failed before selection: %v", err)
|
||||||
|
}
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
if selector == "directory" {
|
||||||
|
_, diagnostics = selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: true\n")
|
||||||
|
} else {
|
||||||
|
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, "workspace/config", " - glob: 'selected/*/*.yml'\n follow: true\n")
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("selection diagnostics = %#v after %d link operations", diagnostics, filesystem.operations)
|
||||||
|
}
|
||||||
|
if filesystem.operations == 0 || filesystem.operations > filesystem.limit {
|
||||||
|
t.Fatalf("link-resolution operations = %d, want 1..%d", filesystem.operations, filesystem.limit)
|
||||||
|
}
|
||||||
|
if filesystem.readLinks < linkCount {
|
||||||
|
t.Fatalf("link reads = %d, want the selector to resolve the repeated dependency graph", filesystem.readLinks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeLinkResolutionRejectsEscapesAndSkipsWhenFollowingIsDisabled(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||||
|
selected := filepath.Join(configurationRoot, "selected")
|
||||||
|
if err := os.MkdirAll(selected, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("../../../outside", filepath.Join(selected, "escape")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
base := os.DirFS(root)
|
||||||
|
if _, ok := base.(fs.ReadLinkFS); !ok {
|
||||||
|
t.Skip("host directory filesystem does not expose link inspection")
|
||||||
|
}
|
||||||
|
filesystem := newCountingReadLinkFS(base, 100)
|
||||||
|
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
||||||
|
if _, err := resolveNativeImportPath(access, filesystem.links, "workspace/config/selected/escape"); !errors.Is(err, errImportPathOutsideRoot) {
|
||||||
|
t.Fatalf("escaping native link error = %v, want outside-root rejection", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesystem.readLinks = 0
|
||||||
|
_, diagnostics := selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: false\n")
|
||||||
|
if len(diagnostics) != 0 || filesystem.readLinks != 0 {
|
||||||
|
t.Fatalf("follow-disabled selection diagnostics=%#v readlink calls=%d", diagnostics, filesystem.readLinks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
||||||
|
// The small graph stays within host symlink-depth limits while exceeding the
|
||||||
|
// bounded linear operation allowance without completed-target reuse.
|
||||||
|
const linkCount = 5
|
||||||
|
root := t.TempDir()
|
||||||
|
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||||
|
graph := filepath.Join(configurationRoot, "graph")
|
||||||
|
selected := filepath.Join(configurationRoot, "selected")
|
||||||
|
for _, directory := range []string{graph, selected} {
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := 0; index < linkCount; index++ {
|
||||||
|
name := fmt.Sprintf("s%02d", index)
|
||||||
|
target := "."
|
||||||
|
if index > 0 {
|
||||||
|
previous := fmt.Sprintf("s%02d", index-1)
|
||||||
|
target = previous + "/" + previous
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(graph, name)); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(graph, "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
aliasTarget := fmt.Sprintf("../graph/s%02d", linkCount-1)
|
||||||
|
if err := os.Symlink(aliasTarget, filepath.Join(selected, "entry")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
containedAbsolute := filepath.Join(graph, "s00")
|
||||||
|
if err := os.Symlink(containedAbsolute, filepath.Join(selected, "absolute")); err != nil {
|
||||||
|
t.Skipf("absolute symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.Symlink(outside, filepath.Join(selected, "escape")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
name := filepath.ToSlash(filepath.Join("workspace", "config", "selected", "entry"))
|
||||||
|
operations := hostImportLinkOperations(access)
|
||||||
|
var operationCount int
|
||||||
|
lstat := operations.lstat
|
||||||
|
readlink := operations.readlink
|
||||||
|
operations.lstat = func(candidate string) (fs.FileInfo, error) {
|
||||||
|
operationCount++
|
||||||
|
if operationCount > linkCount*20+40 {
|
||||||
|
return nil, errors.New("host link operation limit exceeded")
|
||||||
|
}
|
||||||
|
return lstat(candidate)
|
||||||
|
}
|
||||||
|
operations.readlink = func(candidate string) (string, error) {
|
||||||
|
operationCount++
|
||||||
|
if operationCount > linkCount*20+40 {
|
||||||
|
return "", errors.New("host link operation limit exceeded")
|
||||||
|
}
|
||||||
|
return readlink(candidate)
|
||||||
|
}
|
||||||
|
resolved, err := resolveImportLinkPath(name, operations)
|
||||||
|
if err != nil || resolved != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
||||||
|
t.Fatalf("host repeated-link resolution = %q, %v", resolved, err)
|
||||||
|
}
|
||||||
|
if operationCount == 0 || operationCount > linkCount*20+40 {
|
||||||
|
t.Fatalf("host link operations = %d, want 1..%d", operationCount, linkCount*20+40)
|
||||||
|
}
|
||||||
|
|
||||||
|
contained, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "absolute"))
|
||||||
|
if err != nil || contained != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
||||||
|
t.Fatalf("contained absolute link = %q, %v", contained, err)
|
||||||
|
}
|
||||||
|
if _, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "escape")); !errors.Is(err, errImportPathOutsideRoot) {
|
||||||
|
t.Fatalf("escaping absolute link error = %v, want outside-root rejection", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, selector := range []string{"directory", "glob"} {
|
||||||
|
t.Run(selector, func(t *testing.T) {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
if selector == "directory" {
|
||||||
|
_, diagnostics = selectDirectoryWithDiagnostics(t, access, root, " - dir: workspace/config/selected/entry\n follow: true\n")
|
||||||
|
} else {
|
||||||
|
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, root, " - glob: 'workspace/config/selected/entry/*.yml'\n follow: true\n")
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("host selector diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostLinkResolutionKeepsNamespacesIndependent(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
}{
|
||||||
|
{name: "left", target: "left-target"},
|
||||||
|
{name: "right", target: "right-target"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, test.target), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(test.target, filepath.Join(root, "alias")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
if got, err := resolveHostImportPath(access, "alias"); err != nil || got != test.target {
|
||||||
|
t.Fatalf("namespace link = %q, %v; want %q", got, err, test.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReadLinkFS struct {
|
||||||
|
filesystem fs.FS
|
||||||
|
links fs.ReadLinkFS
|
||||||
|
operations int
|
||||||
|
readLinks int
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCountingReadLinkFS(filesystem fs.FS, limit int) *countingReadLinkFS {
|
||||||
|
links, ok := filesystem.(fs.ReadLinkFS)
|
||||||
|
if !ok {
|
||||||
|
panic("test filesystem does not support link inspection")
|
||||||
|
}
|
||||||
|
return &countingReadLinkFS{filesystem: filesystem, links: links, limit: limit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingReadLinkFS) Open(name string) (fs.File, error) { return f.filesystem.Open(name) }
|
||||||
|
|
||||||
|
func (f *countingReadLinkFS) Stat(name string) (fs.FileInfo, error) {
|
||||||
|
if statter, ok := f.filesystem.(fs.StatFS); ok {
|
||||||
|
return statter.Stat(name)
|
||||||
|
}
|
||||||
|
return fs.Stat(f.filesystem, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingReadLinkFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||||
|
if reader, ok := f.filesystem.(fs.ReadDirFS); ok {
|
||||||
|
return reader.ReadDir(name)
|
||||||
|
}
|
||||||
|
return fs.ReadDir(f.filesystem, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingReadLinkFS) count() error {
|
||||||
|
f.operations++
|
||||||
|
if f.operations > f.limit {
|
||||||
|
return errors.New("native link operation limit exceeded")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingReadLinkFS) Lstat(name string) (fs.FileInfo, error) {
|
||||||
|
if err := f.count(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.links.Lstat(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingReadLinkFS) ReadLink(name string) (string, error) {
|
||||||
|
if err := f.count(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
f.readLinks++
|
||||||
|
return f.links.ReadLink(name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,45 +56,13 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
matched, err := path.Match(segment, entry.Name())
|
candidate, selected, diagnostic := prepareGlobEntry(access, basePath, parent, entry, segment, last, policy)
|
||||||
if err != nil {
|
if diagnostic != nil {
|
||||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
return importSelection{}, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
if selected {
|
||||||
continue
|
nextByPath[candidate.path] = candidate
|
||||||
}
|
}
|
||||||
accessPath := access.join(parent.accessPath, entry.Name())
|
|
||||||
lexicalPath := access.join(parent.path, entry.Name())
|
|
||||||
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
|
||||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
|
||||||
}
|
|
||||||
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
|
||||||
if err != nil {
|
|
||||||
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
|
|
||||||
}
|
|
||||||
if target.symlink && !policy.follow {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !last && !target.dir {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
physicalPath := target.identity
|
|
||||||
// Native source identity and relative imports use the namespace
|
|
||||||
// spelling; resolved link identities belong only to discovery.
|
|
||||||
if access.native != nil {
|
|
||||||
physicalPath = accessPath
|
|
||||||
}
|
|
||||||
if physicalPath == "" {
|
|
||||||
physicalPath, err = access.canonical(accessPath)
|
|
||||||
if err != nil {
|
|
||||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidate := selectedImportCandidate{
|
|
||||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
|
||||||
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
|
|
||||||
}
|
|
||||||
nextByPath[candidate.path] = candidate
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
candidates = candidatesFromMap(nextByPath)
|
candidates = candidatesFromMap(nextByPath)
|
||||||
@@ -120,12 +88,53 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
|
|||||||
// Source allowance is charged by source admission, after this filtered set
|
// Source allowance is charged by source admission, after this filtered set
|
||||||
// reaches the traversal; that preserves repeated-source diagnostics first.
|
// reaches the traversal; that preserves repeated-source diagnostics first.
|
||||||
selection := importSelection{candidates: selected}
|
selection := importSelection{candidates: selected}
|
||||||
if len(selected) != 0 {
|
|
||||||
selection.outcome = importSelectionHasCandidates
|
|
||||||
}
|
|
||||||
return selection, nil
|
return selection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareGlobEntry(access *filesystemAccess, basePath string, parent selectedImportCandidate, entry fs.DirEntry, pattern string, last bool, policy resolvedImportPolicy) (selectedImportCandidate, bool, *Diagnostic) {
|
||||||
|
name := entry.Name()
|
||||||
|
matched, err := path.Match(pattern, name)
|
||||||
|
if err != nil {
|
||||||
|
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportInvalid, "import glob pattern is invalid")
|
||||||
|
}
|
||||||
|
if !matched || !policy.hidden && strings.HasPrefix(name, ".") {
|
||||||
|
return selectedImportCandidate{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accessPath := access.join(parent.accessPath, name)
|
||||||
|
lexicalPath := access.join(parent.path, name)
|
||||||
|
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
||||||
|
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")
|
||||||
|
}
|
||||||
|
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
||||||
|
if err != nil {
|
||||||
|
diagnostic := globCandidateDiagnostic(err, parent.site.location, "glob match could not be inspected")
|
||||||
|
return selectedImportCandidate{}, false, &diagnostic
|
||||||
|
}
|
||||||
|
if target.symlink && !policy.follow || !last && !target.dir {
|
||||||
|
return selectedImportCandidate{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
physicalPath := target.identity
|
||||||
|
// Native source identity and relative imports use the namespace spelling;
|
||||||
|
// resolved link identities belong only to discovery.
|
||||||
|
if access.native != nil {
|
||||||
|
physicalPath = accessPath
|
||||||
|
}
|
||||||
|
if physicalPath == "" {
|
||||||
|
physicalPath, err = access.canonical(accessPath)
|
||||||
|
if err != nil {
|
||||||
|
diagnostic := globFilesystemDiagnostic(err, "glob match could not be canonicalized", parent.site.location)
|
||||||
|
return selectedImportCandidate{}, false, &diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedImportCandidate{
|
||||||
|
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
||||||
|
label: relativeImportLabel(access, basePath, lexicalPath), site: parent.site,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
||||||
if !access.contains(candidatePath) || !access.contains(accessPath) {
|
if !access.contains(candidatePath) || !access.contains(accessPath) {
|
||||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||||
@@ -191,7 +200,7 @@ func readGlobEntries(access *filesystemAccess, directory string, location Locati
|
|||||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||||
}
|
}
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if missingDirectoryOpen(err) {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, ba
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
|||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
limits.entries = 2
|
limits.entries = 2
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||||
|
_, _, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
|
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -228,7 +229,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
|||||||
main = parseImportMain(t, mainPath)
|
main = parseImportMain(t, mainPath)
|
||||||
limits = defaultImportLimits()
|
limits = defaultImportLimits()
|
||||||
limits.sources = 2
|
limits.sources = 2
|
||||||
_, _, diagnostics = loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
request = testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||||
|
_, _, diagnostics = testAcquireImportWithLimits(request, limits)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
|
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,48 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoSourceImportsAndFilesystemContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
for _, imports := range []bool{false, true} {
|
||||||
|
name := "imports disabled"
|
||||||
|
if imports {
|
||||||
|
name = "imports enabled"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
result, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{
|
||||||
|
Imports: imports, Filesystem: &FilesystemContext{Root: directory},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 0 {
|
||||||
|
t.Fatalf("NoSource sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("items", "root")); ok {
|
||||||
|
t.Fatal("NoSource contributed explicit input")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outside := t.TempDir()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
context FilesystemContext
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "missing root", context: FilesystemContext{BaseDir: directory}, code: CodeOptionsInvalid},
|
||||||
|
{name: "relative root", context: FilesystemContext{Root: "."}, code: CodeOptionsInvalid},
|
||||||
|
{name: "missing root directory", context: FilesystemContext{Root: filepath.Join(directory, "missing")}, code: CodeImportInvalid},
|
||||||
|
{name: "base outside root", context: FilesystemContext{BaseDir: outside, Root: directory}, code: CodeImportOutsideRoot},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{Filesystem: &test.context}, test.code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
requiredLine := ""
|
requiredLine := ""
|
||||||
|
|||||||
@@ -40,6 +40,86 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *retainedNodeCounter) canAddClones(roots []*document.Node, copies int) bool {
|
||||||
|
if c == nil || c.limit < 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
remaining := c.limit - c.count
|
||||||
|
if remaining < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for copyIndex := 0; copyIndex < copies; copyIndex++ {
|
||||||
|
for _, root := range roots {
|
||||||
|
count, within := documentCloneNodeCount(root, remaining)
|
||||||
|
if !within {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remaining -= count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCloneNodeCount(root *document.Node, limit int) (int, bool) {
|
||||||
|
if root == nil {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
stack := []*document.Node{root}
|
||||||
|
for len(stack) != 0 {
|
||||||
|
node := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
if node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if count > limit {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
stack = append(stack, entry.Value)
|
||||||
|
}
|
||||||
|
stack = append(stack, node.Sequence...)
|
||||||
|
}
|
||||||
|
return count, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInspectionHistory(counter *retainedNodeCounter, inspection *Inspection) bool {
|
||||||
|
if inspection == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, root := range []*document.Node{inspection.baseline, inspection.input, inspection.final, inspection.preCLI} {
|
||||||
|
if !counter.add(root) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, application := range inspection.applications {
|
||||||
|
if !counter.add(application.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, application := range inspection.postDecodeApps {
|
||||||
|
if !counter.add(application.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, snapshots := range [][]resolutionSnapshot{inspection.resolution, inspection.resolutionReplay} {
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
if !counter.add(snapshot.before) || !counter.add(snapshot.after) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, operations := range [][]inheritanceOperation{inspection.inheritance, inspection.metadata.inheritance} {
|
||||||
|
for _, operation := range operations {
|
||||||
|
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// History applications borrow immutable policy-validated input trees. Metadata
|
// History applications borrow immutable policy-validated input trees. Metadata
|
||||||
// records answer presence and origin queries; replay reconstructs value changes.
|
// records answer presence and origin queries; replay reconstructs value changes.
|
||||||
// Replay reconciles with canonical decoding, applies supplied CLI contributions,
|
// Replay reconciles with canonical decoding, applies supplied CLI contributions,
|
||||||
@@ -237,10 +317,6 @@ func applyHistoryApplication(root **document.Node, application historyApplicatio
|
|||||||
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
|
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
|
|
||||||
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
|
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return
|
return
|
||||||
@@ -337,6 +413,27 @@ func applySequenceHistory(target **document.Node, incoming *document.Node, contr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sequenceHistoryContributionForCombination(
|
||||||
|
destination Path,
|
||||||
|
operation sequenceMode,
|
||||||
|
incoming *document.Node,
|
||||||
|
combined sequenceCombinationResult,
|
||||||
|
) sequenceHistoryContribution {
|
||||||
|
contribution := sequenceHistoryContribution{
|
||||||
|
path: destination.clone(), mode: sequenceHistoryMapped,
|
||||||
|
}
|
||||||
|
if operation == sequenceModeReplace {
|
||||||
|
contribution.mode = sequenceHistoryReplace
|
||||||
|
if incoming != nil && incoming.Kind == document.Sequence {
|
||||||
|
contribution.length = len(incoming.Sequence)
|
||||||
|
}
|
||||||
|
return contribution
|
||||||
|
}
|
||||||
|
contribution.items = append([]sequenceItemProjection(nil), combined.higherItems...)
|
||||||
|
contribution.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
|
||||||
|
return contribution
|
||||||
|
}
|
||||||
|
|
||||||
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
|
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -503,66 +600,3 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
|
|
||||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
|
|
||||||
seen := make(map[*document.Node]bool)
|
|
||||||
count := 0
|
|
||||||
var visit func(*document.Node) bool
|
|
||||||
visit = func(node *document.Node) bool {
|
|
||||||
if node == nil || seen[node] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
seen[node] = true
|
|
||||||
count++
|
|
||||||
if count > limit {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
if !visit(entry.Value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, child := range node.Sequence {
|
|
||||||
if !visit(child) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, application := range applications {
|
|
||||||
if !visit(application.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, resolution := range resolutions {
|
|
||||||
if !visit(resolution.before) || !visit(resolution.after) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, expansion := range inherited {
|
|
||||||
if expansion == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !visit(expansion.root) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, operation := range expansion.operations {
|
|
||||||
if !visit(operation.parent) || !visit(operation.effective) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, replacement := range expansion.replacements {
|
|
||||||
if !visit(replacement.before) || !visit(replacement.after) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
210
internal/engine/history_budget_test.go
Normal file
210
internal/engine/history_budget_test.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type historyBudgetSimpleConfig struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyBudgetRecord struct {
|
||||||
|
Value string
|
||||||
|
Credential Secret
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyBudgetLayeredConfig struct {
|
||||||
|
Parent historyBudgetRecord
|
||||||
|
Child historyBudgetRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainedInspectionNodeCountForTest(inspection *Inspection) int {
|
||||||
|
seen := make(map[*document.Node]struct{})
|
||||||
|
stack := []*document.Node{inspection.baseline, inspection.input, inspection.final, inspection.preCLI}
|
||||||
|
for _, application := range inspection.applications {
|
||||||
|
stack = append(stack, application.value)
|
||||||
|
}
|
||||||
|
for _, application := range inspection.postDecodeApps {
|
||||||
|
stack = append(stack, application.value)
|
||||||
|
}
|
||||||
|
for _, snapshot := range inspection.resolution {
|
||||||
|
stack = append(stack, snapshot.before, snapshot.after)
|
||||||
|
}
|
||||||
|
for _, snapshot := range inspection.resolutionReplay {
|
||||||
|
stack = append(stack, snapshot.before, snapshot.after)
|
||||||
|
}
|
||||||
|
for _, operation := range inspection.inheritance {
|
||||||
|
stack = append(stack, operation.parent, operation.effective)
|
||||||
|
}
|
||||||
|
for _, operation := range inspection.metadata.inheritance {
|
||||||
|
stack = append(stack, operation.parent, operation.effective)
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(stack) != 0 {
|
||||||
|
last := len(stack) - 1
|
||||||
|
node := stack[last]
|
||||||
|
stack = stack[:last]
|
||||||
|
if node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[node]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[node] = struct{}{}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
stack = append(stack, entry.Value)
|
||||||
|
}
|
||||||
|
stack = append(stack, node.Sequence...)
|
||||||
|
}
|
||||||
|
return len(seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHistoryBudgetUsesExactRetainedNodeCount(t *testing.T) {
|
||||||
|
load := func(historyLimit int, validations *int) (*Loaded[historyBudgetSimpleConfig], error) {
|
||||||
|
return loadWithSecretLookupLimit(
|
||||||
|
Bytes("main", []byte("value: retained\n")),
|
||||||
|
testSecretBinding(),
|
||||||
|
func(string) (string, bool) { return "", false },
|
||||||
|
historyLimit,
|
||||||
|
Options[historyBudgetSimpleConfig]{Definition: Definition[historyBudgetSimpleConfig]{
|
||||||
|
Validate: func(*historyBudgetSimpleConfig, *Validation) { *validations++ },
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var baselineValidations int
|
||||||
|
baseline, err := load(10000, &baselineValidations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("baseline load: %v", err)
|
||||||
|
}
|
||||||
|
want := retainedInspectionNodeCountForTest(baseline.Inspection)
|
||||||
|
if want == 0 {
|
||||||
|
t.Fatal("independent retained-tree count was empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var exactValidations int
|
||||||
|
loaded, err := load(want, &exactValidations)
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
|
||||||
|
}
|
||||||
|
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
|
||||||
|
t.Fatalf("retained node count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if exactValidations != 1 {
|
||||||
|
t.Fatalf("validation calls at exact count = %d, want 1", exactValidations)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rejectedValidations int
|
||||||
|
loaded, err = load(want-1, &rejectedValidations)
|
||||||
|
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
|
||||||
|
t.Fatalf("load below retained-node count: result=%v error=%v", loaded, err)
|
||||||
|
}
|
||||||
|
if rejectedValidations != 0 {
|
||||||
|
t.Fatalf("validation ran before history rejection: %d calls", rejectedValidations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredHistoryBudgetRejectsBeforeSecretsAndValidation(t *testing.T) {
|
||||||
|
type callbackCounts struct {
|
||||||
|
lookups int
|
||||||
|
validations int
|
||||||
|
}
|
||||||
|
makeRequest := func(counts *callbackCounts) loadRequest[historyBudgetLayeredConfig] {
|
||||||
|
return loadRequest[historyBudgetLayeredConfig]{
|
||||||
|
main: loadInput{source: Bytes("main", []byte("child:\n extends: parent\n"))},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("underlay", []byte(
|
||||||
|
"parent:\n value: inherited\n credential: HISTORY_BUDGET_SECRET\n",
|
||||||
|
))}}}},
|
||||||
|
overrides: []Override{{Path: Keys("child", "value"), Value: "cli", Flag: "--child-value"}},
|
||||||
|
definition: Definition[historyBudgetLayeredConfig]{
|
||||||
|
Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error {
|
||||||
|
configuration.Child.Value += "-resolved"
|
||||||
|
resolution.Record(Keys("child", "value"), ChangeNormalization, "normalize child")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lookup := func(counts *callbackCounts) secretLookup {
|
||||||
|
return func(name string) (string, bool) {
|
||||||
|
counts.lookups++
|
||||||
|
if name != "HISTORY_BUDGET_SECRET" {
|
||||||
|
t.Errorf("secret lookup name = %q", name)
|
||||||
|
}
|
||||||
|
return "resolved-secret", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baselineCounts := &callbackCounts{}
|
||||||
|
baseline, err := loadGroupedWithSecretLookupLimit(makeRequest(baselineCounts), testSecretBinding(), lookup(baselineCounts), 10000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("baseline grouped load: %v", err)
|
||||||
|
}
|
||||||
|
want := retainedInspectionNodeCountForTest(baseline.Inspection)
|
||||||
|
if want == 0 {
|
||||||
|
t.Fatal("independent retained-tree count was empty")
|
||||||
|
}
|
||||||
|
if len(baseline.Inspection.inheritance) == 0 || len(baseline.Inspection.metadata.inheritance) == 0 {
|
||||||
|
t.Fatal("layered load did not retain both inheritance evidence copies")
|
||||||
|
}
|
||||||
|
exactCounts := &callbackCounts{}
|
||||||
|
loaded, err := loadGroupedWithSecretLookupLimit(makeRequest(exactCounts), testSecretBinding(), lookup(exactCounts), want)
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("grouped load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
|
||||||
|
}
|
||||||
|
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
|
||||||
|
t.Fatalf("grouped retained node count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if loaded.Config.Child.Value != "cli-resolved" || exactCounts.lookups != 1 || exactCounts.validations != 1 {
|
||||||
|
t.Fatalf("grouped lifecycle result=%#v callbacks=%#v", loaded.Config, exactCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedCounts := &callbackCounts{}
|
||||||
|
loaded, err = loadGroupedWithSecretLookupLimit(makeRequest(rejectedCounts), testSecretBinding(), lookup(rejectedCounts), want-1)
|
||||||
|
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
|
||||||
|
t.Fatalf("grouped load below retained-node count: result=%v error=%v", loaded, err)
|
||||||
|
}
|
||||||
|
if rejectedCounts.lookups != 0 || rejectedCounts.validations != 0 {
|
||||||
|
t.Fatalf("callbacks ran before history rejection: %#v", rejectedCounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedInheritanceEvidenceOwnsDistinctTrees(t *testing.T) {
|
||||||
|
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "shared"}}}}
|
||||||
|
resolved := resolvedLoad[historyBudgetSimpleConfig]{
|
||||||
|
inheritance: &inheritanceScope{operations: []inheritanceOperation{{parent: root, effective: root}}},
|
||||||
|
}
|
||||||
|
counter := newRetainedNodeCounter(8)
|
||||||
|
inspection, ok := prepareInspectionHistory(resolved, counter)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("preparation rejected the exact size of the distinct retained copies")
|
||||||
|
}
|
||||||
|
if len(inspection.inheritance) != 1 || len(inspection.metadata.inheritance) != 1 {
|
||||||
|
t.Fatalf("inheritance copies = inspection %#v, metadata %#v", inspection.inheritance, inspection.metadata.inheritance)
|
||||||
|
}
|
||||||
|
inspectionOperation := inspection.inheritance[0]
|
||||||
|
metadataOperation := inspection.metadata.inheritance[0]
|
||||||
|
if inspectionOperation.parent == metadataOperation.parent || inspectionOperation.effective == metadataOperation.effective || counter.count != 8 {
|
||||||
|
t.Fatalf("separate inheritance copies were not counted independently: inspection=(%p, %p) metadata=(%p, %p) count=%d", inspectionOperation.parent, inspectionOperation.effective, metadataOperation.parent, metadataOperation.effective, counter.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasHistoryLimitDiagnostic(t *testing.T, err error) bool {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
loadErr, ok := err.(*LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("error type = %T, want *LoadError", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeExplainLimitExceeded {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
|
|
||||||
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
||||||
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||||
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
|
{selector: sequenceIdentitySelector{
|
||||||
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}},
|
literalSequenceSelector("collections"), anyMappingSequenceSelector(), literalSequenceSelector("records"),
|
||||||
|
}, identityFields: []string{"id"}},
|
||||||
})
|
})
|
||||||
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n")
|
first := parseCompositionDocument(t, "source-1", "collections: {north: {records: [{id: a, value: first}]}, south: {records: [{id: x}]}}\n")
|
||||||
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
|
second := parseCompositionDocument(t, "source-2", "collections: {north: {records: [{id: b, value: second}]}, south: {records: [{id: y}]}}\n")
|
||||||
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("compose diagnostics = %#v", diagnostics)
|
t.Fatalf("compose diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
for applicationIndex, application := range composed.applications {
|
for applicationIndex, application := range composed.applications {
|
||||||
for path, want := range map[string]int{"/records": 1, "/other": 1} {
|
for path, want := range map[string]int{"/collections/north/records": 1, "/collections/south/records": 1} {
|
||||||
sequence, present := documentAtPath(application.value, mustParsePath(t, path))
|
sequence, present := documentAtPath(application.value, mustParsePath(t, path))
|
||||||
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want {
|
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want {
|
||||||
t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present)
|
t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present)
|
||||||
@@ -29,14 +30,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nother: [{id: default}]\n")
|
working := parseCompositionDocument(t, "defaults", "collections: {north: {records: [{id: default}]}, south: {records: [{id: default}]}}\n")
|
||||||
index := new(mappingIndex)
|
index := new(mappingIndex)
|
||||||
for _, application := range composed.applications {
|
for _, application := range composed.applications {
|
||||||
applyHistoryApplication(&working, application, index)
|
applyHistoryApplication(&working, application, index)
|
||||||
}
|
}
|
||||||
for path, want := range map[string][]string{
|
for path, want := range map[string][]string{
|
||||||
"/records": {"a", "b"},
|
"/collections/north/records": {"a", "b"},
|
||||||
"/other": {"x", "y"},
|
"/collections/south/records": {"x", "y"},
|
||||||
} {
|
} {
|
||||||
sequence, present := documentAtPath(working, mustParsePath(t, path))
|
sequence, present := documentAtPath(working, mustParsePath(t, path))
|
||||||
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
|
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
|
||||||
@@ -53,14 +54,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
|||||||
application := composed.applications[1]
|
application := composed.applications[1]
|
||||||
cloned := cloneHistoryApplications(composed.applications)
|
cloned := cloneHistoryApplications(composed.applications)
|
||||||
cloned[1].sequences[0].path.segments[0].key = "mutated"
|
cloned[1].sequences[0].path.segments[0].key = "mutated"
|
||||||
if composed.applications[1].sequences[0].path.String() != "/records" {
|
if composed.applications[1].sequences[0].path.String() != "/collections/north/records" {
|
||||||
t.Fatal("cloned sequence history path aliases its source")
|
t.Fatal("cloned sequence history path aliases its source")
|
||||||
}
|
}
|
||||||
addedPath := Keys("records").Index(1).Key("value")
|
addedPath := Keys("collections", "north", "records").Index(1).Key("value")
|
||||||
if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() {
|
if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() {
|
||||||
t.Fatalf("appended value operation = %s, %v", operation.String(), relevant)
|
t.Fatalf("appended value operation = %s, %v", operation.String(), relevant)
|
||||||
}
|
}
|
||||||
if _, relevant := applicationOperationPath(application, Keys("records").Index(0).Key("value"), nil); relevant {
|
if _, relevant := applicationOperationPath(application, Keys("collections", "north", "records").Index(0).Key("value"), nil); relevant {
|
||||||
t.Fatal("later contribution was reported as changing an earlier record")
|
t.Fatal("later contribution was reported as changing an earlier record")
|
||||||
}
|
}
|
||||||
origin := historyApplicationOrigin(application, addedPath, nil)
|
origin := historyApplicationOrigin(application, addedPath, nil)
|
||||||
@@ -69,10 +70,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
overlay := historyApplication{
|
overlay := historyApplication{
|
||||||
layer: LayerOverlay,
|
layer: LayerOverlay,
|
||||||
value: parseCompositionDocument(t, "overlay", "records: []\n"),
|
value: parseCompositionDocument(t, "overlay", "collections: {north: {records: []}}\n"),
|
||||||
}
|
}
|
||||||
applyHistoryApplication(&working, overlay, index)
|
applyHistoryApplication(&working, overlay, index)
|
||||||
replaced, present := documentAtPath(working, Keys("records"))
|
replaced, present := documentAtPath(working, Keys("collections", "north", "records"))
|
||||||
if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 {
|
if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 {
|
||||||
t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present)
|
t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present)
|
||||||
}
|
}
|
||||||
@@ -469,21 +470,18 @@ func TestSequenceHistoryReplaysCombinationEvidence(t *testing.T) {
|
|||||||
t.Fatalf("combine diagnostics = %#v", diagnostics)
|
t.Fatalf("combine diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
mode := sequenceHistoryMapped
|
|
||||||
if test.operation == sequenceModeReplace {
|
|
||||||
mode = sequenceHistoryReplace
|
|
||||||
}
|
|
||||||
length := 0
|
|
||||||
if mode == sequenceHistoryReplace {
|
|
||||||
length = len(combined.higherItems)
|
|
||||||
}
|
|
||||||
application := historyApplication{
|
application := historyApplication{
|
||||||
layer: LayerOverlay,
|
layer: LayerOverlay,
|
||||||
value: higherRoot,
|
value: higherRoot,
|
||||||
sequences: []sequenceHistoryContribution{{
|
sequences: []sequenceHistoryContribution{
|
||||||
path: Keys("records"), mode: mode, length: length,
|
sequenceHistoryContributionForCombination(Keys("records"), test.operation, higher, combined),
|
||||||
items: combined.higherItems, replacements: combined.replacements,
|
},
|
||||||
}},
|
}
|
||||||
|
if len(combined.higherItems) != 0 {
|
||||||
|
combined.higherItems[0].outputIndex++
|
||||||
|
}
|
||||||
|
if len(combined.replacements) != 0 {
|
||||||
|
combined.replacements[0].outputIndex++
|
||||||
}
|
}
|
||||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||||
replayed := document.Clone(lowerRoot)
|
replayed := document.Clone(lowerRoot)
|
||||||
|
|||||||
528
internal/engine/import_acquisition.go
Normal file
528
internal/engine/import_acquisition.go
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importAdmissionOutcome uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
importAdmissionReady importAdmissionOutcome = iota
|
||||||
|
importAdmissionOptionalMissing
|
||||||
|
)
|
||||||
|
|
||||||
|
type admittedImport struct {
|
||||||
|
canonical string
|
||||||
|
identity resourceIdentity
|
||||||
|
nodeKey importNodeKey
|
||||||
|
source SourceInfo
|
||||||
|
sourceIndex int
|
||||||
|
payload *document.Node
|
||||||
|
declarations []importDeclarationSpec
|
||||||
|
sequenceDeclarations []sequenceDeclarationSpec
|
||||||
|
processing *sourceVersionProcessingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type importAdmissionResult struct {
|
||||||
|
outcome importAdmissionOutcome
|
||||||
|
source admittedImport
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (*composedDocument, []SourceInfo, *resolvedFilesystemContext, []Diagnostic) {
|
||||||
|
source := request.input.input.source
|
||||||
|
context := request.input.input.filesystem
|
||||||
|
main := request.main
|
||||||
|
resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
if resolved.native == nil {
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access, err := openResolvedFilesystemAccess(resolved)
|
||||||
|
if err != nil {
|
||||||
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
|
diagnostic.Primary = nil
|
||||||
|
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
|
||||||
|
}
|
||||||
|
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return composition, sources, nil, diagnostics
|
||||||
|
}
|
||||||
|
return composition, sources, &resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
closed := false
|
||||||
|
closeAccess := func() error {
|
||||||
|
if closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
return access.close()
|
||||||
|
}
|
||||||
|
defer func() { _ = closeAccess() }()
|
||||||
|
|
||||||
|
main := request.main
|
||||||
|
destination := request.input.input.destination
|
||||||
|
limits := policy.importLimits
|
||||||
|
accounting := loadState.accounting
|
||||||
|
processing := request.processing
|
||||||
|
basePath, mainPath := resolved.basePath, resolved.mainPath
|
||||||
|
if tier.visited == nil {
|
||||||
|
tier.visited = make(map[resourceIdentity]importRoute)
|
||||||
|
}
|
||||||
|
visited := tier.visited
|
||||||
|
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
||||||
|
_ = closeAccess()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
||||||
|
}
|
||||||
|
for _, directory := range []string{access.rootPath, basePath} {
|
||||||
|
info, err := access.stat(directory)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
_ = closeAccess()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainInfo := cloneSourceInfo(*main.source)
|
||||||
|
mainInfo.Destination = destination.clone()
|
||||||
|
mainIsItem := main.source.placementMode == PlacementModeSequenceItem
|
||||||
|
if mainPath != "" {
|
||||||
|
mainInfo.Path = mainPath
|
||||||
|
}
|
||||||
|
mainResource := access.identity(mainPath)
|
||||||
|
mainNodeKey := importKeyFor(access, mainPath, mainPath == "")
|
||||||
|
if mainPath == "" {
|
||||||
|
mainResource = access.syntheticIdentity()
|
||||||
|
}
|
||||||
|
if previous, repeated := visited[mainResource]; repeated {
|
||||||
|
_ = closeAccess()
|
||||||
|
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||||
|
}
|
||||||
|
accounting.sources++
|
||||||
|
accounting.bytes += main.bytes
|
||||||
|
accounting.nodes += countInputNodes(main.document)
|
||||||
|
directiveContext := request.input.directiveContext
|
||||||
|
if directiveContext == 0 {
|
||||||
|
directiveContext = rootDirectiveContextImportsOnly
|
||||||
|
}
|
||||||
|
if processing != nil && processing.directiveContext != 0 {
|
||||||
|
directiveContext = processing.directiveContext
|
||||||
|
}
|
||||||
|
request.input.directiveContext = directiveContext
|
||||||
|
state := importTraversal{
|
||||||
|
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
||||||
|
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
||||||
|
sources: []SourceInfo{mainInfo},
|
||||||
|
active: make(map[resourceIdentity]int),
|
||||||
|
importGraph: newImportInclusionGraph(),
|
||||||
|
}
|
||||||
|
if mainIsItem {
|
||||||
|
state.itemScope = true
|
||||||
|
state.itemPrefix = destination.clone()
|
||||||
|
state.itemFallback = destination.clone()
|
||||||
|
state.scope.destination = Path{}
|
||||||
|
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
||||||
|
_ = closeAccess()
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainIdentity := mainPath
|
||||||
|
if mainIdentity == "" {
|
||||||
|
mainIdentity = "<selected-source>"
|
||||||
|
}
|
||||||
|
mainRoute := importRoute{canonical: mainIdentity}
|
||||||
|
state.importGraph.addNode(importGraphNode{key: mainNodeKey, canonical: mainIdentity, sourceID: mainInfo.ID, role: importRoleMain})
|
||||||
|
mainRoute.key = mainNodeKey
|
||||||
|
state.active[mainResource] = 0
|
||||||
|
tier.visited[mainResource] = mainRoute
|
||||||
|
state.stack = append(state.stack, mainRoute)
|
||||||
|
var body *document.Node
|
||||||
|
var declarations []importDeclarationSpec
|
||||||
|
var sequenceDeclarations []sequenceDeclarationSpec
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
if processing != nil {
|
||||||
|
body = processing.payload
|
||||||
|
declarations = processing.imports
|
||||||
|
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
|
||||||
|
} else {
|
||||||
|
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||||
|
}
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
if mainIsItem {
|
||||||
|
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain,
|
||||||
|
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
||||||
|
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||||
|
})
|
||||||
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}, 0)
|
||||||
|
} else {
|
||||||
|
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
placementNodes := destinationWrapperNodeCount(destination)
|
||||||
|
if err != nil {
|
||||||
|
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||||
|
} else if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||||
|
} else {
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
||||||
|
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||||
|
destination: destination.clone(), processing: processing,
|
||||||
|
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||||
|
})
|
||||||
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
scheduleAcquisitionScopes(&state.scope, indexImportDependencies(state.importGraph))
|
||||||
|
}
|
||||||
|
|
||||||
|
closeErr := closeAccess()
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
if mainIsItem {
|
||||||
|
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
|
||||||
|
}
|
||||||
|
return nil, cloneSourceInfos(state.sources), diagnostics
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
|
}
|
||||||
|
acquisition := state.scope
|
||||||
|
if mainIsItem {
|
||||||
|
localScope := state.scope
|
||||||
|
acquisition = acquisitionScope{
|
||||||
|
owner: mainNodeKey, destination: destination.clone(),
|
||||||
|
contributions: []acquiredContribution{{
|
||||||
|
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||||
|
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
|
||||||
|
processing: processing, sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations), pendingItem: &localScope,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
return &composedDocument{
|
||||||
|
acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
|
||||||
|
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
|
||||||
|
}, cloneSourceInfos(state.sources), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
|
if s.state.accounting.sources > s.policy.importLimits.sources {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if s.state.accounting.bytes > s.policy.importLimits.bytes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if s.state.accounting.nodes > s.policy.importLimits.nodes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
||||||
|
policies := make([]resolvedImportPolicy, len(declarations))
|
||||||
|
for index, spec := range declarations {
|
||||||
|
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
||||||
|
site := spec.fieldSites["placement"]
|
||||||
|
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
||||||
|
}
|
||||||
|
policies[index] = policy
|
||||||
|
}
|
||||||
|
for index, spec := range declarations {
|
||||||
|
policy := policies[index]
|
||||||
|
var selection importSelection
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
switch spec.selector {
|
||||||
|
case importSelectorPath:
|
||||||
|
selection, diagnostics = selectNamedImportCandidates(s.access, basePath, spec, policy)
|
||||||
|
case importSelectorDirectory:
|
||||||
|
selection, diagnostics = selectDirectoryImportCandidates(s.access, basePath, spec, policy, s.policy.importLimits, s.state.accounting)
|
||||||
|
case importSelectorGlob:
|
||||||
|
selection, diagnostics = selectGlobImportCandidates(s.access, basePath, spec, policy, s.policy.importLimits, s.state.accounting)
|
||||||
|
default:
|
||||||
|
location := spec.location
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import selector is invalid", &location)}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
childDestination := appendDestination(parentDestination, policy.destination)
|
||||||
|
// Count admitted records rather than lexical candidates: optional missing
|
||||||
|
// paths are omitted during admission, and a partially present list is not empty.
|
||||||
|
sourcesBefore := len(s.sources)
|
||||||
|
for _, candidate := range selection.candidates {
|
||||||
|
if diagnostics := s.visitPath(candidate, spec, policy, childDestination, policy.destination, depth+1); len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !policy.required && len(s.sources) == sourcesBefore {
|
||||||
|
s.recordEmptyImportSelection(sourceIndex, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) recordEmptyImportSelection(sourceIndex int, spec importDeclarationSpec) {
|
||||||
|
// The declaring source's position survives descendant appends and optional
|
||||||
|
// missing-source rollback, unlike a pointer into the source slice.
|
||||||
|
s.sources[sourceIndex].importSelections = append(s.sources[sourceIndex].importSelections, importSelectionEvidence{
|
||||||
|
path: spec.path.clone(), location: spec.location,
|
||||||
|
selector: spec.selector, outcome: importSelectionNone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) admitImportSource(canonical string, identity resourceIdentity, nodeKey importNodeKey, candidate selectedImportCandidate, policy resolvedImportPolicy, destination Path, location Location) (importAdmissionResult, []Diagnostic) {
|
||||||
|
sourceID := "source-" + strconv.Itoa(s.state.accounting.sources+1)
|
||||||
|
kind := SourceKindFile
|
||||||
|
label := candidate.label
|
||||||
|
if label == "" {
|
||||||
|
label = candidate.path
|
||||||
|
}
|
||||||
|
if s.access.native != nil {
|
||||||
|
kind = SourceKindFilesystem
|
||||||
|
label = s.access.label + "/" + canonical
|
||||||
|
}
|
||||||
|
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||||
|
if policy.placement == PlacementModeSequenceItem {
|
||||||
|
info.placementMode = PlacementModeSequenceItem
|
||||||
|
}
|
||||||
|
// A source's own item placement still has an exact sequence-container
|
||||||
|
// destination. Only an unresolved enclosing item index makes the path a
|
||||||
|
// fallback container for this snapshot.
|
||||||
|
info.destinationUnresolved = s.itemScope
|
||||||
|
if kind == SourceKindFilesystem {
|
||||||
|
info.namespace = s.access.token
|
||||||
|
}
|
||||||
|
sourceIndex := len(s.sources)
|
||||||
|
s.sources = append(s.sources, info)
|
||||||
|
data, skipped, diagnostic := s.readImport(canonical, sourceID, policy.required)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return importAdmissionResult{}, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
s.sources = s.sources[:len(s.sources)-1]
|
||||||
|
return importAdmissionResult{outcome: importAdmissionOptionalMissing}, nil
|
||||||
|
}
|
||||||
|
s.state.accounting.sources++
|
||||||
|
|
||||||
|
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
||||||
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||||
|
})
|
||||||
|
if len(issues) != 0 {
|
||||||
|
return importAdmissionResult{}, parserDiagnostics(sourceID, issues)
|
||||||
|
}
|
||||||
|
nodeCount := countInputNodes(root)
|
||||||
|
if s.state.accounting.nodes+nodeCount > s.policy.importLimits.nodes {
|
||||||
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)
|
||||||
|
return importAdmissionResult{}, []Diagnostic{value}
|
||||||
|
}
|
||||||
|
s.state.accounting.nodes += nodeCount
|
||||||
|
|
||||||
|
directiveContext := s.request.input.directiveContext
|
||||||
|
if directiveContext == 0 {
|
||||||
|
directiveContext = rootDirectiveContextImportsOnly
|
||||||
|
}
|
||||||
|
versionDestination := destination
|
||||||
|
if s.itemScope {
|
||||||
|
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
|
||||||
|
}
|
||||||
|
var processing sourceVersionProcessingResult
|
||||||
|
if s.state.versionState != nil && s.state.versionState.mode != sourceVersionModeDefault {
|
||||||
|
processed, failures := s.state.versionState.processWithDirectiveContext(root, sourceVersionContribution, versionDestination, sourceID, policy.placement, directiveContext)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return importAdmissionResult{}, failures
|
||||||
|
}
|
||||||
|
processing = processed
|
||||||
|
} else {
|
||||||
|
processed := processSourceVersionWithDirectiveContext(root, s.request.input.protectionRule, sourceVersionContribution, versionDestination, sourceVersionModeDefault, directiveContext)
|
||||||
|
if len(processed.diagnostics) != 0 {
|
||||||
|
return importAdmissionResult{}, processed.diagnostics
|
||||||
|
}
|
||||||
|
processing = processed
|
||||||
|
}
|
||||||
|
return importAdmissionResult{source: admittedImport{
|
||||||
|
canonical: canonical, identity: identity, nodeKey: nodeKey,
|
||||||
|
source: cloneSourceInfo(info), sourceIndex: sourceIndex, payload: processing.payload,
|
||||||
|
declarations: processing.imports,
|
||||||
|
sequenceDeclarations: cloneSequenceDeclarations(processing.sequences),
|
||||||
|
processing: &processing,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec importDeclarationSpec, policy resolvedImportPolicy, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||||
|
location := candidate.site.location
|
||||||
|
if location.SourceID == "" && location.Line == 0 && location.Column == 0 {
|
||||||
|
location = spec.location
|
||||||
|
}
|
||||||
|
if !s.access.contains(candidate.accessPath) {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
||||||
|
}
|
||||||
|
canonical, err := s.access.canonical(candidate.accessPath)
|
||||||
|
if err != nil {
|
||||||
|
if !policy.required && errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code := CodeImportInvalid
|
||||||
|
message := "import target could not be canonicalized"
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
code, message = CodeImportMissing, "required import target does not exist"
|
||||||
|
} else if errors.Is(err, fs.ErrPermission) {
|
||||||
|
message = "import target cannot be accessed"
|
||||||
|
}
|
||||||
|
return []Diagnostic{importDiagnostic(code, message, &location)}
|
||||||
|
}
|
||||||
|
if !s.access.contains(canonical) {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
||||||
|
}
|
||||||
|
if depth > s.policy.importLimits.depth {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRoute := s.currentRoute(location)
|
||||||
|
identity := s.access.identity(canonical)
|
||||||
|
nodeKey := importKeyFor(s.access, canonical, false)
|
||||||
|
importerKey := s.currentImportNode()
|
||||||
|
if activeIndex, ok := s.active[identity]; ok {
|
||||||
|
closingEdge := s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||||
|
if diagnostic, ok := s.importGraph.scheduleCycle(closingEdge, location); ok {
|
||||||
|
return []Diagnostic{diagnostic}
|
||||||
|
}
|
||||||
|
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
||||||
|
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||||
|
}
|
||||||
|
if previous, ok := s.tier.visited[identity]; ok {
|
||||||
|
s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||||
|
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||||
|
}
|
||||||
|
if s.state.accounting.sources+1 > s.policy.importLimits.sources {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.access.relative(canonical); err != nil {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
||||||
|
}
|
||||||
|
admission, diagnostics := s.admitImportSource(canonical, identity, nodeKey, candidate, policy, destination, location)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
if admission.outcome == importAdmissionOptionalMissing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
admitted := admission.source
|
||||||
|
|
||||||
|
placementDestination := destination
|
||||||
|
mode := PlacementModeMapping
|
||||||
|
if policy.placement == PlacementModeSequenceItem {
|
||||||
|
mode = PlacementModeSequenceItem
|
||||||
|
}
|
||||||
|
role := importRoleMapping
|
||||||
|
if mode == PlacementModeSequenceItem {
|
||||||
|
role = importRoleItem
|
||||||
|
}
|
||||||
|
s.importGraph.addNode(importGraphNode{key: admitted.nodeKey, canonical: admitted.canonical, sourceID: admitted.source.ID, role: role})
|
||||||
|
s.importGraph.addEdge(importerKey, admitted.nodeKey, location)
|
||||||
|
if mode == PlacementModeSequenceItem {
|
||||||
|
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
|
||||||
|
s.active[admitted.identity] = len(s.stack)
|
||||||
|
s.tier.visited[admitted.identity] = route
|
||||||
|
s.stack = append(s.stack, route)
|
||||||
|
diagnostics := s.visitItemSource(admitted, destination, relativeDestination, depth)
|
||||||
|
delete(s.active, admitted.identity)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
_, placed, err := placeDocument(admitted.payload, placementDestination, admitted.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCount(placementDestination)
|
||||||
|
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
|
}
|
||||||
|
s.state.accounting.nodes += placementNodes
|
||||||
|
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
|
||||||
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(admitted.source), payload: admitted.payload, document: placed,
|
||||||
|
identity: admitted.nodeKey, role: role, scopeOwner: s.scope.owner,
|
||||||
|
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: admitted.processing,
|
||||||
|
sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
|
||||||
|
})
|
||||||
|
s.active[admitted.identity] = len(s.stack)
|
||||||
|
s.tier.visited[admitted.identity] = route
|
||||||
|
s.stack = append(s.stack, route)
|
||||||
|
diagnostics = s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, destination, admitted.sourceIndex)
|
||||||
|
delete(s.active, admitted.identity)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) currentImportNode() importNodeKey {
|
||||||
|
if len(s.stack) != 0 {
|
||||||
|
return s.stack[len(s.stack)-1].key
|
||||||
|
}
|
||||||
|
key := s.scope.owner
|
||||||
|
if _, exists := s.importGraph.index[key]; !exists {
|
||||||
|
s.importGraph.addNode(importGraphNode{key: key, canonical: "<selected-source>", role: importRoleMain})
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) sourceDestination(destination Path) Path {
|
||||||
|
if !s.itemScope {
|
||||||
|
return destination.clone()
|
||||||
|
}
|
||||||
|
// Until the enclosing item receives its assigned index, only the collection
|
||||||
|
// container is exact. Keep the local suffix in the acquisition projection.
|
||||||
|
return s.itemFallback.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) readImport(name, sourceID string, required bool) ([]byte, bool, *Diagnostic) {
|
||||||
|
remaining := s.policy.importLimits.bytes - s.state.accounting.bytes
|
||||||
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
|
if limit < 0 {
|
||||||
|
limit = 0
|
||||||
|
}
|
||||||
|
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
|
||||||
|
if failure != nil {
|
||||||
|
if !required && failure.missingTarget {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
|
return nil, false, &value
|
||||||
|
}
|
||||||
|
s.state.accounting.bytes += int64(len(data))
|
||||||
|
return data, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) currentRoute(location Location) importRoute {
|
||||||
|
locations := make([]Location, 0, len(s.stack)+1)
|
||||||
|
for _, route := range s.stack {
|
||||||
|
if len(route.locations) != 0 {
|
||||||
|
locations = append(locations, route.locations[len(route.locations)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locations = append(locations, location)
|
||||||
|
return importRoute{locations: locations}
|
||||||
|
}
|
||||||
@@ -2,11 +2,54 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBroadEmptyImportSelectionsKeepDeclaringSourceOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- glob: fragments/*.yml
|
||||||
|
- path: []
|
||||||
|
`)
|
||||||
|
const fragments = 32
|
||||||
|
for index := 0; index < fragments; index++ {
|
||||||
|
writeImportFile(t, directory, fmt.Sprintf("fragments/fragment-%02d.yml", index), `configkit:
|
||||||
|
imports:
|
||||||
|
- path: missing.yml
|
||||||
|
- path: []
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
loaded, err := testLoad[map[string]any](File(mainPath), Options[map[string]any]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != fragments+1 {
|
||||||
|
t.Fatalf("source count = %d, want %d", len(sources), fragments+1)
|
||||||
|
}
|
||||||
|
for index, source := range sources {
|
||||||
|
wantPaths := []string{"/configkit/imports/0", "/configkit/imports/1"}
|
||||||
|
if index == 0 {
|
||||||
|
wantPaths = wantPaths[1:]
|
||||||
|
}
|
||||||
|
if len(source.importSelections) != len(wantPaths) {
|
||||||
|
t.Fatalf("source %s selections = %#v", source.ID, source.importSelections)
|
||||||
|
}
|
||||||
|
for selectionIndex, selection := range source.importSelections {
|
||||||
|
if selection.path.String() != wantPaths[selectionIndex] || selection.location.SourceID != source.ID ||
|
||||||
|
selection.selector != importSelectorPath || selection.outcome != importSelectionNone {
|
||||||
|
t.Errorf("source %s selection %d = %#v", source.ID, selectionIndex, selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) {
|
func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,20 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
|
// importDependencyIndex is built once per scheduling operation and shared by
|
||||||
|
// its scopes. Inclusion targets are grouped by importer, so local scheduling
|
||||||
|
// need not scan or reserve storage for unrelated graph edges.
|
||||||
|
type importDependencyIndex map[importNodeKey][]importNodeKey
|
||||||
|
|
||||||
|
func indexImportDependencies(graph importInclusionGraph) importDependencyIndex {
|
||||||
|
index := make(importDependencyIndex)
|
||||||
|
for _, edge := range graph.edges {
|
||||||
|
index[edge.importer] = append(index[edge.importer], edge.imported)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleAcquisitionScope(scope acquisitionScope, dependencies importDependencyIndex) acquisitionScope {
|
||||||
slots := make([]int, 0, len(scope.contributions))
|
slots := make([]int, 0, len(scope.contributions))
|
||||||
for index, contribution := range scope.contributions {
|
for index, contribution := range scope.contributions {
|
||||||
if contribution.scopeOwner != scope.owner {
|
if contribution.scopeOwner != scope.owner {
|
||||||
@@ -166,7 +179,7 @@ func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph
|
|||||||
slots = append(slots, index)
|
slots = append(slots, index)
|
||||||
}
|
}
|
||||||
scheduled := scope
|
scheduled := scope
|
||||||
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, graph)
|
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, dependencies)
|
||||||
return scheduled
|
return scheduled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +192,10 @@ func scheduleAcquisitionGroup(scopes []acquisitionScope, graph importInclusionGr
|
|||||||
for index := range slots {
|
for index := range slots {
|
||||||
slots[index] = index
|
slots[index] = index
|
||||||
}
|
}
|
||||||
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, graph)}
|
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, indexImportDependencies(graph))}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, graph importInclusionGraph) []acquiredContribution {
|
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, dependencies importDependencyIndex) []acquiredContribution {
|
||||||
if len(slots) < 2 {
|
if len(slots) < 2 {
|
||||||
return append([]acquiredContribution(nil), contributions...)
|
return append([]acquiredContribution(nil), contributions...)
|
||||||
}
|
}
|
||||||
@@ -193,12 +206,12 @@ func scheduleContributionSlots(contributions []acquiredContribution, slots []int
|
|||||||
positions[contribution.identity] = position
|
positions[contribution.identity] = position
|
||||||
keys[position] = contribution.identity.dependencyKey()
|
keys[position] = contribution.identity.dependencyKey()
|
||||||
}
|
}
|
||||||
edges := make([]dependencyEdge, 0, len(graph.edges))
|
var edges []dependencyEdge
|
||||||
for _, edge := range graph.edges {
|
for importerKey, importer := range positions {
|
||||||
importer, importerOK := positions[edge.importer]
|
for _, importedKey := range dependencies[importerKey] {
|
||||||
imported, importedOK := positions[edge.imported]
|
if imported, exists := positions[importedKey]; exists {
|
||||||
if importerOK && importedOK {
|
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
||||||
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
order, _ := scheduleDependencies(keys, edges)
|
order, _ := scheduleDependencies(keys, edges)
|
||||||
|
|||||||
277
internal/engine/import_projection.go
Normal file
277
internal/engine/import_projection.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
func prependCompositionDiagnosticPaths(diagnostics []Diagnostic, prefix Path) []Diagnostic {
|
||||||
|
cloned := append([]Diagnostic(nil), diagnostics...)
|
||||||
|
for index := range cloned {
|
||||||
|
if !isCompositionDiagnostic(cloned[index]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cloned[index].Path = appendDestination(prefix, cloned[index].Path)
|
||||||
|
cloned[index].Primary = cloneLocation(cloned[index].Primary)
|
||||||
|
cloned[index].Related = cloneLocations(cloned[index].Related)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCompositionDiagnostic(diagnostic Diagnostic) bool {
|
||||||
|
return diagnostic.Code == CodeComposeConflict || diagnostic.Code == CodeComposeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a composition failure only the earlier contributions are needed to locate
|
||||||
|
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
|
||||||
|
// and access diagnostics keep their original source-local coordinates.
|
||||||
|
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path, beforeContribution int) []Diagnostic {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if isCompositionDiagnostic(diagnostic) {
|
||||||
|
prefix := parent
|
||||||
|
prefix.contributions = parent.contributions[:beforeContribution]
|
||||||
|
index := priorSequenceLength([]acquisitionScope{prefix}, destination)
|
||||||
|
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
// This scan is confined to failed loads, not the successful construction path.
|
||||||
|
func priorSequenceLength(scopes []acquisitionScope, path Path) int {
|
||||||
|
length := 0
|
||||||
|
for _, scope := range scopes {
|
||||||
|
for _, contribution := range scope.contributions {
|
||||||
|
if node, present := documentAtPath(contribution.document, path); present && node.Kind == document.Sequence {
|
||||||
|
length += len(node.Sequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectPriorInputDiagnosticPaths(diagnostics []Diagnostic, scopes []acquisitionScope) []Diagnostic {
|
||||||
|
cloned := cloneDiagnostics(diagnostics)
|
||||||
|
for index := range cloned {
|
||||||
|
if !isCompositionDiagnostic(cloned[index]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for position, segment := range cloned[index].Path.segments {
|
||||||
|
if segment.kind != pathSegmentIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
container := Path{segments: cloned[index].Path.segments[:position]}
|
||||||
|
cloned[index].Path.segments[position].index += priorSequenceLength(scopes, container)
|
||||||
|
// Registration does not address paths through item indexes. A prior
|
||||||
|
// input shifts the outer collection, not its item-local sequences.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
|
||||||
|
if composed == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applications := composed.applications
|
||||||
|
projectedApplications := make([]historyApplication, 0, len(applications))
|
||||||
|
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||||
|
for sourceIndex, contribution := range scope.contributions {
|
||||||
|
if contribution.nested == nil {
|
||||||
|
projectedApplications = append(projectedApplications, applications[sourceIndex])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemIndex := 0
|
||||||
|
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found {
|
||||||
|
if projected, ok := assigned.outputForInput(0); ok {
|
||||||
|
itemIndex = projected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix := contribution.destination.Index(itemIndex)
|
||||||
|
if sourceIndex < len(applications) {
|
||||||
|
outer := applications[sourceIndex]
|
||||||
|
structural := document.Clone(outer.value)
|
||||||
|
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > 0 {
|
||||||
|
// The retained fragment is still a local singleton. Its assigned
|
||||||
|
// outer index belongs to the projection, not this source tree.
|
||||||
|
node.Sequence[0] = &document.Node{Kind: document.Mapping, Location: node.Sequence[0].Location}
|
||||||
|
}
|
||||||
|
outer.value = structural
|
||||||
|
projectedApplications = append(projectedApplications, outer)
|
||||||
|
for _, nested := range contribution.nested.applications {
|
||||||
|
projected := nested
|
||||||
|
projected.path = appendDestination(prefix, nested.path)
|
||||||
|
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
|
||||||
|
projected.sequencesByPath = indexSequenceHistoryContributions(projected.sequences)
|
||||||
|
projectedApplications = append(projectedApplications, projected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
|
||||||
|
}
|
||||||
|
composed.applications = projectedApplications
|
||||||
|
projectNestedCollectionPolicies(composed, scope, ranges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectNestedCollectionPolicies(composed *composedDocument, scope acquisitionScope, ranges map[sequenceRangeKey]sequenceRange) {
|
||||||
|
if composed == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type itemPolicyProjection struct {
|
||||||
|
destination Path
|
||||||
|
policy collectionPolicy
|
||||||
|
base int
|
||||||
|
}
|
||||||
|
projected := make(map[string]*itemPolicyProjection)
|
||||||
|
var prefixLengths map[string]int
|
||||||
|
for sourceIndex, contribution := range scope.contributions {
|
||||||
|
if prefixLengths != nil && sourceIndex > 0 {
|
||||||
|
addSequencePrefixLengths(scope.contributions[sourceIndex-1].document, Path{}, prefixLengths)
|
||||||
|
}
|
||||||
|
if contribution.nested == nil || len(contribution.nested.collectionPolicies.collections) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := contribution.destination.String()
|
||||||
|
assigned := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: key}]
|
||||||
|
itemIndex, found := assigned.outputForInput(0)
|
||||||
|
if !found {
|
||||||
|
// Normal composition supplies the assignment directly. For a
|
||||||
|
// handoff without ranges, scan each preceding tree only once.
|
||||||
|
if prefixLengths == nil {
|
||||||
|
prefixLengths = make(map[string]int)
|
||||||
|
for _, previous := range scope.contributions[:sourceIndex] {
|
||||||
|
addSequencePrefixLengths(previous.document, Path{}, prefixLengths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemIndex = prefixLengths[key]
|
||||||
|
}
|
||||||
|
working, initialized := projected[key]
|
||||||
|
if !initialized {
|
||||||
|
// Read one owned copy per destination, then add each incoming
|
||||||
|
// record without recopying earlier records and their scopes.
|
||||||
|
policy, _ := composed.collectionPolicies.at(contribution.destination)
|
||||||
|
working = &itemPolicyProjection{destination: contribution.destination, policy: policy, base: len(policy.recordOrder)}
|
||||||
|
projected[key] = working
|
||||||
|
}
|
||||||
|
working.policy.setRecordPolicyAt(working.base+itemIndex, collectionRecordPolicy{
|
||||||
|
owner: newCollectionRecordPolicy().owner,
|
||||||
|
scope: contribution.nested.collectionPolicies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, working := range projected {
|
||||||
|
composed.collectionPolicies.set(working.destination, working.policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSequencePrefixLengths(node *document.Node, path Path, lengths map[string]int) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if node.Kind == document.Sequence {
|
||||||
|
lengths[path.String()] += len(node.Sequence)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Contribution destinations select mapping paths, not sequence members.
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
addSequencePrefixLengths(entry.Value, path.Key(entry.Key), lengths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
|
||||||
|
var visit func(*document.Node, Path)
|
||||||
|
visit = func(node *document.Node, path Path) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if origin, ok := origins[path.String()]; ok {
|
||||||
|
target[appendDestination(prefix, path).String()] = cloneOrigin(origin)
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
visit(entry.Value, path.Key(entry.Key))
|
||||||
|
}
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
visit(child, path.Index(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(root, Path{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectAcquiredDestinations(composed *composedDocument, scope acquisitionScope, sources []SourceInfo) {
|
||||||
|
byID := make(map[string]int, len(sources))
|
||||||
|
for index := range sources {
|
||||||
|
byID[sources[index].ID] = index
|
||||||
|
}
|
||||||
|
var projectScope func(*composedDocument, acquisitionScope, Path, bool, string)
|
||||||
|
projectScope = func(currentComposition *composedDocument, current acquisitionScope, prefix Path, insideItem bool, parentSourceID string) {
|
||||||
|
ranges := indexSequenceRanges(currentComposition.sequenceRanges)
|
||||||
|
for sourceIndex, contribution := range current.contributions {
|
||||||
|
if contribution.source.ID == parentSourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contributionPrefix := prefix
|
||||||
|
contributionInsideItem := insideItem
|
||||||
|
itemIndex := 0
|
||||||
|
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found {
|
||||||
|
if projected, ok := assigned.outputForInput(0); ok {
|
||||||
|
itemIndex = projected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contribution.source.placementMode == PlacementModeSequenceItem {
|
||||||
|
container := appendDestination(contributionPrefix, contribution.destination)
|
||||||
|
contribution.source.Destination = container.clone()
|
||||||
|
contribution.source.destinationUnresolved = false
|
||||||
|
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
|
||||||
|
if position, ok := byID[contribution.source.ID]; ok {
|
||||||
|
sources[position].Destination = container
|
||||||
|
sources[position].destinationUnresolved = false
|
||||||
|
}
|
||||||
|
contributionPrefix = appendDestination(container, Path{}.Index(itemIndex))
|
||||||
|
contributionInsideItem = true
|
||||||
|
projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex)
|
||||||
|
} else if contributionInsideItem {
|
||||||
|
exactDestination := appendDestination(contributionPrefix, contribution.destination)
|
||||||
|
contribution.source.Destination = exactDestination.clone()
|
||||||
|
contribution.source.destinationUnresolved = false
|
||||||
|
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
|
||||||
|
if position, ok := byID[contribution.source.ID]; ok {
|
||||||
|
sources[position].Destination = exactDestination
|
||||||
|
sources[position].destinationUnresolved = false
|
||||||
|
}
|
||||||
|
projectSourceVersionEvidence(contribution.processing, exactDestination, nil, sourceIndex)
|
||||||
|
} else {
|
||||||
|
projectSourceVersionEvidence(contribution.processing, contribution.destination, ranges, sourceIndex)
|
||||||
|
}
|
||||||
|
if contribution.nested != nil && contribution.nested.acquisition != nil {
|
||||||
|
for _, nestedScope := range contribution.nested.acquisition {
|
||||||
|
projectScope(contribution.nested, nestedScope, contributionPrefix, contributionInsideItem, contribution.source.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectScope(composed, scope, Path{}, false, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path, ranges sequenceRangeIndex, sourceIndex int) {
|
||||||
|
if processing != nil && processing.evidence != nil {
|
||||||
|
evidence := processing.evidence
|
||||||
|
evidence.destination = destination.clone()
|
||||||
|
// Rebuild from source-local paths on each handoff: an input can be
|
||||||
|
// composed first on its own and then at a different offset in its tier.
|
||||||
|
evidence.projectedPaths = nil
|
||||||
|
paths := evidence.sensitivityPaths()
|
||||||
|
for index, path := range paths {
|
||||||
|
for position := range path.segments {
|
||||||
|
prefix := Path{segments: path.segments[:position]}
|
||||||
|
assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: prefix.String()}]
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, ok := schemaIndex(path.segments[position])
|
||||||
|
if ok && path.segments[position].kind != pathSegmentKey {
|
||||||
|
if outputIndex, projected := assigned.outputForInput(item); projected {
|
||||||
|
path.segments[position] = pathSegment{kind: pathSegmentIndex, index: outputIndex}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths[index] = path
|
||||||
|
}
|
||||||
|
evidence.projectedPaths = paths
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/engine/import_root_panic_test.go
Normal file
108
internal/engine/import_root_panic_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportedAcquisitionClosesOwnedHostRootOnEveryExit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
main string
|
||||||
|
child string
|
||||||
|
version bool
|
||||||
|
wantError bool
|
||||||
|
wantPanic bool
|
||||||
|
}{
|
||||||
|
{name: "success", main: "configkit: {imports: [{path: child.yml}]}\n", child: "value: child\n"},
|
||||||
|
{name: "error", main: "configkit: {imports: [{path: missing.yml, required: true}]}\n", wantError: true},
|
||||||
|
{name: "version panic", main: "configkit: {imports: [{path: child.yml}]}\n", child: "version: v1\n", version: true, wantPanic: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
rootPath := t.TempDir()
|
||||||
|
mainPath := filepath.Join(rootPath, "main.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte(test.main), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.child != "" {
|
||||||
|
if err := os.WriteFile(filepath.Join(rootPath, "child.yml"), []byte(test.child), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source := File(mainPath)
|
||||||
|
main, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("main parse diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ownedRoot := access.root
|
||||||
|
sentinel := errors.New("imported version checker panic sentinel")
|
||||||
|
var rule *VersionRule
|
||||||
|
var versionState *sourceVersionState
|
||||||
|
if test.version {
|
||||||
|
rule = &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { panic(sentinel) }}
|
||||||
|
versionState = &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
|
||||||
|
}
|
||||||
|
request := testImportRequest(source, nil, main, Path{}, rule, nil)
|
||||||
|
policy := &acquisitionPolicy{importLimits: defaultImportLimits()}
|
||||||
|
state := &acquisitionState{accounting: &importAccounting{}, versionState: versionState}
|
||||||
|
tier := &tierAcquisitionState{}
|
||||||
|
resolved := resolvedFilesystemContext{rootPath: rootPath, basePath: rootPath, mainPath: mainPath}
|
||||||
|
var resultDiagnostics []Diagnostic
|
||||||
|
var recovered any
|
||||||
|
func() {
|
||||||
|
defer func() { recovered = recover() }()
|
||||||
|
_, _, resultDiagnostics = acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
||||||
|
}()
|
||||||
|
if test.wantPanic {
|
||||||
|
if recovered != sentinel {
|
||||||
|
t.Fatalf("recovered panic = %#v, want original sentinel", recovered)
|
||||||
|
}
|
||||||
|
} else if recovered != nil {
|
||||||
|
t.Fatalf("unexpected panic = %#v", recovered)
|
||||||
|
}
|
||||||
|
if test.wantError != (len(resultDiagnostics) != 0) {
|
||||||
|
t.Fatalf("diagnostics = %#v, want error=%v", resultDiagnostics, test.wantError)
|
||||||
|
}
|
||||||
|
if _, err := ownedRoot.Stat("."); err == nil {
|
||||||
|
t.Fatal("owned host root remained usable after acquisition returned")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportedAcquisitionClosesChildFileBeforeVersionPanic(t *testing.T) {
|
||||||
|
filesystem := &observedFilesystem{files: fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}]}\n")},
|
||||||
|
"child.yml": &fstest.MapFile{Data: []byte("version: v1\n")},
|
||||||
|
}}
|
||||||
|
namespace := newNativeNamespace("bundle", filesystem)
|
||||||
|
source := filesystemFile(namespace, "main.yml")
|
||||||
|
main, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("main parse diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
sentinel := errors.New("imported version checker panic sentinel")
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { panic(sentinel) }}
|
||||||
|
versionState := &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
|
||||||
|
request := testImportRequest(source, nil, main, Path{}, rule, nil)
|
||||||
|
access := newNativeAccess(filesystem, ".", namespace.token)
|
||||||
|
var recovered any
|
||||||
|
func() {
|
||||||
|
defer func() { recovered = recover() }()
|
||||||
|
_, _, _ = acquireImportedWithAccess(request, &acquisitionPolicy{importLimits: defaultImportLimits()},
|
||||||
|
&acquisitionState{accounting: &importAccounting{}, versionState: versionState}, &tierAcquisitionState{}, access,
|
||||||
|
resolvedFilesystemContext{native: namespace, rootPath: ".", basePath: ".", mainPath: source.path})
|
||||||
|
}()
|
||||||
|
if recovered != sentinel {
|
||||||
|
t.Fatalf("recovered panic = %#v, want original sentinel", recovered)
|
||||||
|
}
|
||||||
|
filesystem.assertClosed(t)
|
||||||
|
}
|
||||||
287
internal/engine/import_scope.go
Normal file
287
internal/engine/import_scope.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
func compositionTraversal(policy sequencePolicy, rule *VersionRule, versionState *sourceVersionState, accounting *importAccounting, graph importInclusionGraph) importTraversal {
|
||||||
|
return importTraversal{
|
||||||
|
request: importAcquisitionRequest{input: inputAcquisitionRequest{protectionRule: rule}},
|
||||||
|
policy: &acquisitionPolicy{sequencePolicy: policy, importLimits: defaultImportLimits()},
|
||||||
|
state: &acquisitionState{accounting: accounting, versionState: versionState},
|
||||||
|
tier: &tierAcquisitionState{},
|
||||||
|
importGraph: graph.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeAcquiredInputWithPolicy is the per-input adapter from retained
|
||||||
|
// acquisition scopes to the established strict-composition behavior. It uses
|
||||||
|
// the already acquired documents and graph; source handles are closed before
|
||||||
|
// this handoff.
|
||||||
|
func composeAcquiredInputWithPolicies(input assembledInput, lower collectionPolicyScope, policy sequencePolicy) (*composedDocument, []Diagnostic) {
|
||||||
|
if input.composition == nil || input.composition.root != nil || len(input.composition.acquisition) == 0 {
|
||||||
|
return input.composition, nil
|
||||||
|
}
|
||||||
|
acquired := input.composition
|
||||||
|
if len(acquired.acquisition) != 1 {
|
||||||
|
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "acquired input has an invalid composition scope", nil)}
|
||||||
|
}
|
||||||
|
scope := cloneAcquisitionScope(acquired.acquisition[0])
|
||||||
|
var rule *VersionRule
|
||||||
|
if input.versionState != nil {
|
||||||
|
rule = input.versionState.effectiveRule
|
||||||
|
}
|
||||||
|
accounting := input.accounting
|
||||||
|
if accounting == nil {
|
||||||
|
accounting = &importAccounting{sources: len(input.sources), bytes: acquired.inputBytes, nodes: acquired.inputNodes}
|
||||||
|
}
|
||||||
|
traversal := compositionTraversal(policy, rule, input.versionState, accounting, acquired.importGraph)
|
||||||
|
if len(scope.contributions) == 1 && scope.contributions[0].role == importRoleMain && scope.contributions[0].pendingItem != nil {
|
||||||
|
main := scope.contributions[0]
|
||||||
|
localScope := cloneAcquisitionScope(*main.pendingItem)
|
||||||
|
if diagnostics := traversal.composePendingItems(&localScope); len(diagnostics) != 0 {
|
||||||
|
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
|
||||||
|
}
|
||||||
|
documents := make([]*document.Node, 0, len(localScope.contributions))
|
||||||
|
for _, contribution := range localScope.contributions {
|
||||||
|
documents = append(documents, contribution.document)
|
||||||
|
}
|
||||||
|
completed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
policy, localScope.collectionPolicies,
|
||||||
|
acquiredSequenceDeclarations(localScope), documents...,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
|
||||||
|
}
|
||||||
|
if input.versionState != nil {
|
||||||
|
protectionRule := input.versionState.localRule
|
||||||
|
if input.versionState.mode == sourceVersionModeDefault && protectionRule != nil {
|
||||||
|
prefix := main.destination.Index(0)
|
||||||
|
relative, inside := relativeHistoryPath(prefix, protectionRule.Path)
|
||||||
|
if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) {
|
||||||
|
protectionRule = nil
|
||||||
|
} else {
|
||||||
|
scoped := *protectionRule
|
||||||
|
scoped.Path = relative
|
||||||
|
protectionRule = &scoped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic := importedVersionClaim(documents, localScope, completed, protectionRule); diagnostic != nil {
|
||||||
|
if rule != nil {
|
||||||
|
diagnostic.Path = rule.Path.clone()
|
||||||
|
}
|
||||||
|
return nil, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
|
||||||
|
projectNestedEvidence(completed, localScope)
|
||||||
|
_, placed, err := placeDocumentWithMode(completed.root, main.destination, PlacementModeSequenceItem, main.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||||
|
}
|
||||||
|
if !main.placementCharged {
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(main.destination, PlacementModeSequenceItem)
|
||||||
|
if accounting.nodes+placementNodes > traversal.policy.importLimits.nodes {
|
||||||
|
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", main.source.ImportedFrom)}
|
||||||
|
}
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
}
|
||||||
|
composition, diagnostics := composeDocumentsWithPolicy(policy, placed)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
synthetic := acquisitionScope{owner: main.scopeOwner, contributions: []acquiredContribution{{
|
||||||
|
source: cloneSourceInfo(main.source), payload: completed.root, document: placed,
|
||||||
|
identity: main.identity, role: importRoleMain, scopeOwner: main.scopeOwner,
|
||||||
|
destination: main.destination.clone(), processing: main.processing, nested: completed,
|
||||||
|
}}}
|
||||||
|
projectNestedEvidence(composition, synthetic)
|
||||||
|
projectAcquiredDestinations(composition, synthetic, input.sources)
|
||||||
|
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
|
||||||
|
composition.inputBytes = accounting.bytes
|
||||||
|
composition.inputNodes = accounting.nodes
|
||||||
|
composition.importGraph = acquired.importGraph.clone()
|
||||||
|
return composition, nil
|
||||||
|
}
|
||||||
|
return composeAcquiredScopeWithPolicy(
|
||||||
|
&scope, lower, policy, acquired.importGraph, input.sources, rule, input.versionState, accounting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeAcquiredScopeWithPolicy is shared by per-input and underlay-group
|
||||||
|
// handoffs. Lower policy is carried separately from registered application
|
||||||
|
// constraints; current strict composition continues to use its existing rules.
|
||||||
|
func composeAcquiredScopeWithPolicy(
|
||||||
|
scope *acquisitionScope,
|
||||||
|
lower collectionPolicyScope,
|
||||||
|
policy sequencePolicy,
|
||||||
|
graph importInclusionGraph,
|
||||||
|
sources []SourceInfo,
|
||||||
|
rule *VersionRule,
|
||||||
|
versionState *sourceVersionState,
|
||||||
|
accounting *importAccounting,
|
||||||
|
) (*composedDocument, []Diagnostic) {
|
||||||
|
traversal := compositionTraversal(policy, rule, versionState, accounting, graph)
|
||||||
|
if diagnostics := traversal.composePendingItems(scope); len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
documents := make([]*document.Node, 0, len(scope.contributions))
|
||||||
|
for _, contribution := range scope.contributions {
|
||||||
|
documents = append(documents, contribution.document)
|
||||||
|
}
|
||||||
|
composition, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
policy, lower, acquiredSequenceDeclarations(*scope), documents...,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
if diagnostic := importedVersionClaim(documents, *scope, composition, rule); diagnostic != nil {
|
||||||
|
return nil, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
projectNestedEvidence(composition, *scope)
|
||||||
|
projectAcquiredDestinations(composition, *scope, sources)
|
||||||
|
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(*scope)}
|
||||||
|
composition.inputBytes = accounting.bytes
|
||||||
|
composition.inputNodes = accounting.nodes
|
||||||
|
composition.importGraph = graph.clone()
|
||||||
|
return composition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic {
|
||||||
|
if scope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for index := range scope.contributions {
|
||||||
|
contribution := &scope.contributions[index]
|
||||||
|
if contribution.pendingItem == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, PlacementModeSequenceItem)
|
||||||
|
if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
contribution.placementCharged = true
|
||||||
|
if diagnostic := reservePendingItemPlacements(contribution.pendingItem, limits, accounting); diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) visitItemSource(admitted admittedImport, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||||
|
parentScope := s.scope
|
||||||
|
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
||||||
|
s.scope = acquisitionScope{owner: admitted.nodeKey}
|
||||||
|
s.itemScope = true
|
||||||
|
if parentItemScope {
|
||||||
|
s.itemPrefix = appendDestination(parentPrefix, destination)
|
||||||
|
} else {
|
||||||
|
s.itemPrefix = destination.clone()
|
||||||
|
s.itemFallback = destination.clone()
|
||||||
|
}
|
||||||
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(admitted.source), payload: admitted.payload, document: admitted.payload,
|
||||||
|
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: admitted.nodeKey,
|
||||||
|
processing: admitted.processing, sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
|
||||||
|
})
|
||||||
|
diagnostics := s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, Path{}, admitted.sourceIndex)
|
||||||
|
itemScope := s.scope
|
||||||
|
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
itemScope.destination = Path{}
|
||||||
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(admitted.source), payload: admitted.payload,
|
||||||
|
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: parentScope.owner,
|
||||||
|
destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: admitted.processing,
|
||||||
|
pendingItem: &itemScope,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic {
|
||||||
|
for index := range scope.contributions {
|
||||||
|
contribution := &scope.contributions[index]
|
||||||
|
if contribution.pendingItem == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemScope := contribution.pendingItem
|
||||||
|
if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 {
|
||||||
|
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
|
||||||
|
}
|
||||||
|
documents := make([]*document.Node, 0, len(itemScope.contributions))
|
||||||
|
for _, nested := range itemScope.contributions {
|
||||||
|
documents = append(documents, nested.document)
|
||||||
|
}
|
||||||
|
// An item scope is below a sequence index. Application selectors match
|
||||||
|
// mapping-only effective paths, so none can apply inside this scope.
|
||||||
|
// Its YAML declarations remain relative to the owning record.
|
||||||
|
completed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||||
|
sequencePolicy{}, itemScope.collectionPolicies,
|
||||||
|
acquiredSequenceDeclarations(*itemScope), documents...,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
|
||||||
|
}
|
||||||
|
// Version ownership is checked by the enclosing composition once this
|
||||||
|
// item has its effective coordinates, not against its local field names.
|
||||||
|
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
|
||||||
|
projectNestedEvidence(completed, *itemScope)
|
||||||
|
_, placed, err := placeDocumentWithMode(completed.root, contribution.destination, PlacementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)}
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, PlacementModeSequenceItem)
|
||||||
|
if !contribution.placementCharged {
|
||||||
|
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)}
|
||||||
|
}
|
||||||
|
s.state.accounting.nodes += placementNodes
|
||||||
|
}
|
||||||
|
contribution.payload = completed.root
|
||||||
|
contribution.document = placed
|
||||||
|
contribution.nested = completed
|
||||||
|
contribution.pendingItem = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquiredSequenceDeclarations(scope acquisitionScope) []sequenceDeclarationSpec {
|
||||||
|
var declarations []sequenceDeclarationSpec
|
||||||
|
for _, contribution := range scope.contributions {
|
||||||
|
projected := cloneSequenceDeclarations(contribution.sequenceDeclarations)
|
||||||
|
for index := range projected {
|
||||||
|
projected[index].destination = appendDestination(contribution.relativeDestination, projected[index].destination)
|
||||||
|
}
|
||||||
|
declarations = append(declarations, projected...)
|
||||||
|
}
|
||||||
|
return declarations
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedVersionClaim(documents []*document.Node, scope acquisitionScope, composed *composedDocument, rule *VersionRule) *Diagnostic {
|
||||||
|
if composed == nil || rule == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mainIndex := -1
|
||||||
|
for index, contribution := range scope.contributions {
|
||||||
|
if contribution.role == importRoleMain {
|
||||||
|
mainIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||||
|
for index, contribution := range documents {
|
||||||
|
if index == mainIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node := fragmentVersionClaimWithIndex(contribution, rule, index, ranges); node != nil {
|
||||||
|
location := publicLocation(node.Location)
|
||||||
|
return &Diagnostic{
|
||||||
|
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ type selectedImportCandidate struct {
|
|||||||
// importSelection contains lexical candidates only; admission assigns source identity later.
|
// importSelection contains lexical candidates only; admission assigns source identity later.
|
||||||
type importSelection struct {
|
type importSelection struct {
|
||||||
candidates []selectedImportCandidate
|
candidates []selectedImportCandidate
|
||||||
outcome importSelectionOutcome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
|
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
|
||||||
@@ -68,9 +67,6 @@ func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec
|
|||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
selection := importSelection{candidates: candidates}
|
selection := importSelection{candidates: candidates}
|
||||||
if len(candidates) != 0 {
|
|
||||||
selection.outcome = importSelectionHasCandidates
|
|
||||||
}
|
|
||||||
return selection, nil
|
return selection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,72 +135,23 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
}
|
}
|
||||||
entry := frame.entries[frame.next]
|
entry := frame.entries[frame.next]
|
||||||
frame.next++
|
frame.next++
|
||||||
accessPath := access.join(frame.directory.accessPath, entry.Name())
|
result, diagnostic := prepareImportDirectoryEntry(access, basePath, *frame, entry, policy, limits, accounting, activeDirectories)
|
||||||
lexicalPath := access.join(frame.directory.path, entry.Name())
|
if diagnostic != nil {
|
||||||
candidate := selectedImportCandidate{
|
return importSelection{}, []Diagnostic{*diagnostic}
|
||||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
|
|
||||||
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
|
|
||||||
}
|
}
|
||||||
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
switch result.kind {
|
||||||
|
case importDirectoryEntrySkip:
|
||||||
continue
|
continue
|
||||||
}
|
case importDirectoryEntryFile:
|
||||||
target, entryErr := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
if _, exists := seenCandidates[result.candidate.path]; exists {
|
||||||
if entryErr != nil {
|
|
||||||
if errors.Is(entryErr, fs.ErrNotExist) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(frame.directory.site, entryErr)}
|
seenCandidates[result.candidate.path] = struct{}{}
|
||||||
|
discovered = append(discovered, result.candidate)
|
||||||
|
case importDirectoryEntryDirectory:
|
||||||
|
activeDirectories[result.frame.identity] = struct{}{}
|
||||||
|
stack = append(stack, result.frame)
|
||||||
}
|
}
|
||||||
if target.symlink && !policy.follow {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if target.dir {
|
|
||||||
if !directoryDepthAllows(policy, frame.depth) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := activeDirectories[target.identity]; exists {
|
|
||||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")}
|
|
||||||
}
|
|
||||||
childMissing, childIsDirectory, childDiagnostic := validateNamedImportTarget(access, candidate, false, true)
|
|
||||||
if childDiagnostic != nil {
|
|
||||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
|
||||||
}
|
|
||||||
if childMissing || !childIsDirectory {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childEntries, vanished, childDiagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
|
|
||||||
if childDiagnostic != nil {
|
|
||||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
|
||||||
}
|
|
||||||
if !vanished {
|
|
||||||
if access.native == nil {
|
|
||||||
candidate.accessPath = target.identity
|
|
||||||
}
|
|
||||||
activeDirectories[target.identity] = struct{}{}
|
|
||||||
stack = append(stack, importDirectoryFrame{directory: candidate, depth: frame.depth + 1, entries: childEntries, identity: target.identity})
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if target.identity != "" && access.native == nil {
|
|
||||||
candidate.accessPath = target.identity
|
|
||||||
} else {
|
|
||||||
canonical, canonicalErr := access.canonical(accessPath)
|
|
||||||
if canonicalErr != nil {
|
|
||||||
if errors.Is(canonicalErr, fs.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(canonicalErr, "directory search file could not be canonicalized", frame.directory.site.location)}
|
|
||||||
}
|
|
||||||
candidate.accessPath = canonical
|
|
||||||
}
|
|
||||||
if !matchesImportFilename(entry.Name(), policy.pattern) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seenCandidates[candidate.path]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenCandidates[candidate.path] = struct{}{}
|
|
||||||
discovered = append(discovered, candidate)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path })
|
sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path })
|
||||||
@@ -223,12 +170,76 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
|
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
|
||||||
}
|
}
|
||||||
selection := importSelection{candidates: selected}
|
selection := importSelection{candidates: selected}
|
||||||
if len(selected) != 0 {
|
|
||||||
selection.outcome = importSelectionHasCandidates
|
|
||||||
}
|
|
||||||
return selection, nil
|
return selection, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareImportDirectoryEntry(access *filesystemAccess, basePath string, frame importDirectoryFrame, entry fs.DirEntry, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting, activeDirectories map[string]struct{}) (importDirectoryEntryResult, *Diagnostic) {
|
||||||
|
accessPath := access.join(frame.directory.accessPath, entry.Name())
|
||||||
|
lexicalPath := access.join(frame.directory.path, entry.Name())
|
||||||
|
candidate := selectedImportCandidate{
|
||||||
|
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
|
||||||
|
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
|
||||||
|
}
|
||||||
|
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
target, err := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
return importDirectoryEntryResult{}, importDirectoryTargetDiagnostic(frame.directory.site, err)
|
||||||
|
}
|
||||||
|
if target.symlink && !policy.follow {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
if target.dir {
|
||||||
|
if !directoryDepthAllows(policy, frame.depth) {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
if _, exists := activeDirectories[target.identity]; exists {
|
||||||
|
return importDirectoryEntryResult{}, importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")
|
||||||
|
}
|
||||||
|
missing, isDirectory, diagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return importDirectoryEntryResult{}, diagnostic
|
||||||
|
}
|
||||||
|
if missing || !isDirectory {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
entries, vanished, diagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return importDirectoryEntryResult{}, diagnostic
|
||||||
|
}
|
||||||
|
if vanished {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
if access.native == nil {
|
||||||
|
candidate.accessPath = target.identity
|
||||||
|
}
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntryDirectory, frame: importDirectoryFrame{
|
||||||
|
directory: candidate, depth: frame.depth + 1, entries: entries, identity: target.identity,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
if target.identity != "" && access.native == nil {
|
||||||
|
candidate.accessPath = target.identity
|
||||||
|
} else {
|
||||||
|
canonical, err := access.canonical(accessPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
diagnostic := globFilesystemDiagnostic(err, "directory search file could not be canonicalized", frame.directory.site.location)
|
||||||
|
return importDirectoryEntryResult{}, &diagnostic
|
||||||
|
}
|
||||||
|
candidate.accessPath = canonical
|
||||||
|
}
|
||||||
|
if !matchesImportFilename(entry.Name(), policy.pattern) {
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||||
|
}
|
||||||
|
return importDirectoryEntryResult{kind: importDirectoryEntryFile, candidate: candidate}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type importDirectoryFrame struct {
|
type importDirectoryFrame struct {
|
||||||
directory selectedImportCandidate
|
directory selectedImportCandidate
|
||||||
depth int
|
depth int
|
||||||
@@ -237,6 +248,20 @@ type importDirectoryFrame struct {
|
|||||||
identity string
|
identity string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type importDirectoryEntryKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
importDirectoryEntrySkip importDirectoryEntryKind = iota
|
||||||
|
importDirectoryEntryFile
|
||||||
|
importDirectoryEntryDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
type importDirectoryEntryResult struct {
|
||||||
|
kind importDirectoryEntryKind
|
||||||
|
candidate selectedImportCandidate
|
||||||
|
frame importDirectoryFrame
|
||||||
|
}
|
||||||
|
|
||||||
func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic {
|
func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic {
|
||||||
if isImportLinkCycle(err) {
|
if isImportLinkCycle(err) {
|
||||||
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")
|
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")
|
||||||
@@ -267,7 +292,7 @@ func importDirectoryEntries(access *filesystemAccess, directory string, site imp
|
|||||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||||
return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
|
return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
|
||||||
}
|
}
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if missingDirectoryOpen(err) {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("optional partial selection diagnostics = %#v", diagnostics)
|
t.Fatalf("optional partial selection diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" {
|
if len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" {
|
||||||
t.Fatalf("partial selection = %#v", selection)
|
t.Fatalf("partial selection = %#v", selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
|
|||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true})
|
empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true})
|
||||||
if len(diagnostics) != 0 || empty.outcome != importSelectionNone || len(empty.candidates) != 0 {
|
if len(diagnostics) != 0 || len(empty.candidates) != 0 {
|
||||||
t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics)
|
t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ func TestSelectNamedImportsChecksRequiredTargetsBeforeExclusions(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
@@ -118,12 +118,12 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy)
|
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy)
|
||||||
if len(diagnostics) != 0 || selection.outcome != importSelectionNone {
|
if len(diagnostics) != 0 || len(selection.candidates) != 0 {
|
||||||
t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics)
|
t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
}
|
}
|
||||||
if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
|
if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
|
||||||
@@ -135,7 +135,7 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
|
|||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
hidden := false
|
hidden := false
|
||||||
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], importDefaults{hidden: &hidden})
|
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], ImportDefaults{Hidden: &hidden})
|
||||||
if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
|
if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
|
||||||
t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics)
|
t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics)
|
||||||
}
|
}
|
||||||
@@ -161,8 +161,21 @@ func TestSelectNamedImportsChecksNormalizedPathsLexically(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 || len(selection.candidates) != 2 {
|
if len(diagnostics) != 0 || len(selection.candidates) != 2 {
|
||||||
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
}
|
}
|
||||||
if strings.Join(filesystem.statPaths, ",") != "a.yaml,a.yaml,z.yaml,z.yaml" {
|
if selection.candidates[0].path != "a.yaml" || selection.candidates[1].path != "z.yaml" {
|
||||||
t.Fatalf("target stat order = %#v", filesystem.statPaths)
|
t.Fatalf("candidate order = %#v, want a.yaml then z.yaml", selection.candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
failingFilesystem := &statOnlyImportFilesystem{statErrors: map[string]error{
|
||||||
|
"a.yaml": fs.ErrPermission,
|
||||||
|
"z.yaml": fs.ErrPermission,
|
||||||
|
}}
|
||||||
|
failingAccess := newNativeAccess(failingFilesystem, ".", &namespaceToken{})
|
||||||
|
_, diagnostics = selectNamedImportCandidates(failingAccess, ".", specs[0], resolvedImportPolicy{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != "/configkit/imports/0/path/1" {
|
||||||
|
t.Fatalf("first lexical failure = %#v, want /configkit/imports/0/path/1", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" {
|
||||||
|
t.Fatalf("first lexical failure location = %#v", diagnostics[0].Primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +239,6 @@ type statOnlyImportFilesystem struct {
|
|||||||
statErrors map[string]error
|
statErrors map[string]error
|
||||||
openCalls int
|
openCalls int
|
||||||
statCalls int
|
statCalls int
|
||||||
statPaths []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
|
func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
|
||||||
@@ -236,7 +248,6 @@ func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
|
|||||||
|
|
||||||
func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) {
|
func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) {
|
||||||
filesystem.statCalls++
|
filesystem.statCalls++
|
||||||
filesystem.statPaths = append(filesystem.statPaths, name)
|
|
||||||
if err := filesystem.statErrors[name]; err != nil {
|
if err := filesystem.statErrors[name]; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,23 +35,13 @@ type importDeclarationSpec struct {
|
|||||||
pattern []string
|
pattern []string
|
||||||
patternSet bool
|
patternSet bool
|
||||||
exclude []string
|
exclude []string
|
||||||
excludeSet bool
|
|
||||||
depth int
|
depth int
|
||||||
depthSet bool
|
depthSet bool
|
||||||
destination Path
|
destination Path
|
||||||
placement placementMode
|
placement PlacementMode
|
||||||
fieldSites map[string]importFieldSite
|
fieldSites map[string]importFieldSite
|
||||||
}
|
}
|
||||||
|
|
||||||
// importDefaults are the engine's private copy of load-wide discovery defaults.
|
|
||||||
type importDefaults struct {
|
|
||||||
required *bool
|
|
||||||
follow *bool
|
|
||||||
hidden *bool
|
|
||||||
recursive *bool
|
|
||||||
pattern []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportDefaults transports root-owned load defaults across the facade
|
// ImportDefaults transports root-owned load defaults across the facade
|
||||||
// boundary. Load options snapshot this value before source acquisition.
|
// boundary. Load options snapshot this value before source acquisition.
|
||||||
type ImportDefaults struct {
|
type ImportDefaults struct {
|
||||||
@@ -66,26 +56,6 @@ type ImportDefaults struct {
|
|||||||
// Pattern filters directory-search filenames; nil and empty differ.
|
// Pattern filters directory-search filenames; nil and empty differ.
|
||||||
Pattern []string
|
Pattern []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionsWithImportDefaults copies root-owned defaults into ordinary options.
|
|
||||||
func OptionsWithImportDefaults[T any](options Options[T], value ImportDefaults) Options[T] {
|
|
||||||
options.importDefaults = privateImportDefaults(value)
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
// LayeredOptionsWithImportDefaults copies root-owned defaults into grouped options.
|
|
||||||
func LayeredOptionsWithImportDefaults[T any](options LayeredOptions[T], value ImportDefaults) LayeredOptions[T] {
|
|
||||||
options.importDefaults = privateImportDefaults(value)
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func privateImportDefaults(value ImportDefaults) importDefaults {
|
|
||||||
return snapshotImportDefaults(importDefaults{
|
|
||||||
required: value.Required, follow: value.Follow, hidden: value.Hidden,
|
|
||||||
recursive: value.Recursive, pattern: value.Pattern,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type resolvedImportPolicy struct {
|
type resolvedImportPolicy struct {
|
||||||
required bool
|
required bool
|
||||||
follow bool
|
follow bool
|
||||||
@@ -96,16 +66,16 @@ type resolvedImportPolicy struct {
|
|||||||
depthSet bool
|
depthSet bool
|
||||||
exclude []string
|
exclude []string
|
||||||
destination Path
|
destination Path
|
||||||
placement placementMode
|
placement PlacementMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotImportDefaults(value importDefaults) importDefaults {
|
func snapshotImportDefaults(value ImportDefaults) ImportDefaults {
|
||||||
return importDefaults{
|
return ImportDefaults{
|
||||||
required: cloneBool(value.required),
|
Required: cloneBool(value.Required),
|
||||||
follow: cloneBool(value.follow),
|
Follow: cloneBool(value.Follow),
|
||||||
hidden: cloneBool(value.hidden),
|
Hidden: cloneBool(value.Hidden),
|
||||||
recursive: cloneBool(value.recursive),
|
Recursive: cloneBool(value.Recursive),
|
||||||
pattern: cloneStrings(value.pattern),
|
Pattern: cloneStrings(value.Pattern),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +94,8 @@ func cloneStrings(values []string) []string {
|
|||||||
return append(make([]string, 0, len(values)), values...)
|
return append(make([]string, 0, len(values)), values...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateImportDefaults(value importDefaults) []Diagnostic {
|
func validateImportDefaults(value ImportDefaults) []Diagnostic {
|
||||||
for _, pattern := range value.pattern {
|
for _, pattern := range value.Pattern {
|
||||||
if _, err := path.Match(pattern, ""); err != nil {
|
if _, err := path.Match(pattern, ""); err != nil {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")}
|
||||||
}
|
}
|
||||||
@@ -133,10 +103,9 @@ func validateImportDefaults(value importDefaults) []Diagnostic {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (resolvedImportPolicy, []Diagnostic) {
|
func resolveImportPolicy(spec importDeclarationSpec, defaults ImportDefaults) (resolvedImportPolicy, []Diagnostic) {
|
||||||
defaults = snapshotImportDefaults(defaults)
|
|
||||||
policy := resolvedImportPolicy{
|
policy := resolvedImportPolicy{
|
||||||
required: boolOr(spec.required, defaults.required, false),
|
required: boolOr(spec.required, defaults.Required, false),
|
||||||
follow: true,
|
follow: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
@@ -147,15 +116,15 @@ func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (r
|
|||||||
exclude: cloneStrings(spec.exclude),
|
exclude: cloneStrings(spec.exclude),
|
||||||
}
|
}
|
||||||
if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob {
|
if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob {
|
||||||
policy.follow = boolOr(spec.follow, defaults.follow, true)
|
policy.follow = boolOr(spec.follow, defaults.Follow, true)
|
||||||
policy.hidden = boolOr(spec.hidden, defaults.hidden, true)
|
policy.hidden = boolOr(spec.hidden, defaults.Hidden, true)
|
||||||
}
|
}
|
||||||
if spec.selector == importSelectorDirectory {
|
if spec.selector == importSelectorDirectory {
|
||||||
policy.recursive = boolOr(spec.recursive, defaults.recursive, false)
|
policy.recursive = boolOr(spec.recursive, defaults.Recursive, false)
|
||||||
if spec.patternSet {
|
if spec.patternSet {
|
||||||
policy.pattern = cloneStrings(spec.pattern)
|
policy.pattern = cloneStrings(spec.pattern)
|
||||||
} else {
|
} else {
|
||||||
policy.pattern = cloneStrings(defaults.pattern)
|
policy.pattern = cloneStrings(defaults.Pattern)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if spec.depthSet && !policy.recursive {
|
if spec.depthSet && !policy.recursive {
|
||||||
@@ -180,26 +149,6 @@ func boolOr(explicit, application *bool, fallback bool) bool {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImportSpecificationDirective validates every declaration before callers select candidates.
|
|
||||||
func parseImportSpecificationDirective(node *document.Node, directivePath Path) ([]importDeclarationSpec, []Diagnostic) {
|
|
||||||
if node.Kind != document.Mapping {
|
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
|
||||||
}
|
|
||||||
var specs []importDeclarationSpec
|
|
||||||
var diagnostics []Diagnostic
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
fieldPath := directivePath.Key(entry.Key)
|
|
||||||
if entry.Key != "imports" {
|
|
||||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
|
|
||||||
specs = append(specs, parsed...)
|
|
||||||
diagnostics = append(diagnostics, issues...)
|
|
||||||
}
|
|
||||||
return specs, canonicalDiagnostics(diagnostics)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) {
|
func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) {
|
||||||
if node == nil || node.Kind != document.Sequence {
|
if node == nil || node.Kind != document.Sequence {
|
||||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)}
|
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)}
|
||||||
@@ -224,7 +173,7 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
|||||||
spec := importDeclarationSpec{
|
spec := importDeclarationSpec{
|
||||||
path: declarationPath.clone(),
|
path: declarationPath.clone(),
|
||||||
location: publicLocation(node.Location),
|
location: publicLocation(node.Location),
|
||||||
placement: placementModeMapping,
|
placement: PlacementModeMapping,
|
||||||
fieldSites: make(map[string]importFieldSite, len(node.Mapping)),
|
fieldSites: make(map[string]importFieldSite, len(node.Mapping)),
|
||||||
}
|
}
|
||||||
selectors := 0
|
selectors := 0
|
||||||
@@ -251,40 +200,17 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
|||||||
}
|
}
|
||||||
switch entry.Key {
|
switch entry.Key {
|
||||||
case "required", "recursive", "follow", "hidden":
|
case "required", "recursive", "follow", "hidden":
|
||||||
if entry.Value.Kind != document.Boolean {
|
if diagnostic := parseImportBooleanAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, entry.Key+" must be a boolean", entry.Value)}
|
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||||
}
|
|
||||||
value := entry.Value.Text == "true"
|
|
||||||
switch entry.Key {
|
|
||||||
case "required":
|
|
||||||
spec.required = &value
|
|
||||||
case "recursive":
|
|
||||||
spec.recursive = &value
|
|
||||||
case "follow":
|
|
||||||
spec.follow = &value
|
|
||||||
case "hidden":
|
|
||||||
spec.hidden = &value
|
|
||||||
}
|
}
|
||||||
case "pattern":
|
case "pattern":
|
||||||
values, origins, ok, badIndex := parseStringSet(entry.Value)
|
if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
|
||||||
if !ok {
|
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "pattern", badIndex)}
|
|
||||||
}
|
}
|
||||||
if badIndex, ok := invalidWildcard(values); ok {
|
|
||||||
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
|
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "pattern contains an invalid wildcard expression", badNode)}
|
|
||||||
}
|
|
||||||
spec.pattern, spec.patternSet = values, true
|
|
||||||
case "exclude":
|
case "exclude":
|
||||||
values, origins, ok, badIndex := parseStringSet(entry.Value)
|
if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
|
||||||
if !ok {
|
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "exclude", badIndex)}
|
|
||||||
}
|
}
|
||||||
if badIndex, ok := invalidWildcard(values); ok {
|
|
||||||
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
|
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "exclude contains an invalid wildcard expression", badNode)}
|
|
||||||
}
|
|
||||||
spec.exclude, spec.excludeSet = values, true
|
|
||||||
case "depth":
|
case "depth":
|
||||||
depth, ok := parseImportDepth(entry.Value)
|
depth, ok := parseImportDepth(entry.Value)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -298,17 +224,11 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
|||||||
}
|
}
|
||||||
spec.destination = destination
|
spec.destination = destination
|
||||||
case "placement":
|
case "placement":
|
||||||
if entry.Value.Kind != document.String {
|
placement, diagnostic := parseImportPlacement(entry.Value, fieldPath)
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
if diagnostic != nil {
|
||||||
}
|
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||||
switch entry.Value.Text {
|
|
||||||
case "mapping":
|
|
||||||
spec.placement = placementModeMapping
|
|
||||||
case "sequence-item":
|
|
||||||
spec.placement = placementModeSequenceItem
|
|
||||||
default:
|
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
|
||||||
}
|
}
|
||||||
|
spec.placement = placement
|
||||||
default:
|
default:
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||||
}
|
}
|
||||||
@@ -316,33 +236,98 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
|||||||
|
|
||||||
selectorPath := declarationPath.Key(selectorEntry.Key)
|
selectorPath := declarationPath.Key(selectorEntry.Key)
|
||||||
spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)}
|
spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)}
|
||||||
|
if diagnostic := parseImportSelectorValue(&spec, selectorEntry.Key, selectorEntry.Value, selectorPath); diagnostic != nil {
|
||||||
|
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportBooleanAssignment(spec *importDeclarationSpec, field string, node *document.Node, fieldPath Path) *Diagnostic {
|
||||||
|
if node.Kind != document.Boolean {
|
||||||
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, field+" must be a boolean", node)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
value := node.Text == "true"
|
||||||
|
switch field {
|
||||||
|
case "required":
|
||||||
|
spec.required = &value
|
||||||
|
case "recursive":
|
||||||
|
spec.recursive = &value
|
||||||
|
case "follow":
|
||||||
|
spec.follow = &value
|
||||||
|
case "hidden":
|
||||||
|
spec.hidden = &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportWildcardAssignment(spec *importDeclarationSpec, field string, node *document.Node, fieldPath Path) *Diagnostic {
|
||||||
|
values, origins, ok, badIndex := parseStringSet(node)
|
||||||
|
if !ok {
|
||||||
|
diagnostic := stringSetDiagnostic(node, fieldPath, field, badIndex)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
if badIndex, ok := invalidWildcard(values); ok {
|
||||||
|
badPath, badNode := listMemberSite(node, fieldPath, origins[badIndex])
|
||||||
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, badPath, field+" contains an invalid wildcard expression", badNode)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
switch field {
|
||||||
|
case "pattern":
|
||||||
|
spec.pattern, spec.patternSet = values, true
|
||||||
|
case "exclude":
|
||||||
|
spec.exclude = values
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportPlacement(node *document.Node, fieldPath Path) (PlacementMode, *Diagnostic) {
|
||||||
|
if node.Kind != document.String {
|
||||||
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
|
||||||
|
return PlacementModeMapping, &diagnostic
|
||||||
|
}
|
||||||
|
switch node.Text {
|
||||||
|
case "mapping":
|
||||||
|
return PlacementModeMapping, nil
|
||||||
|
case "sequence-item":
|
||||||
|
return PlacementModeSequenceItem, nil
|
||||||
|
default:
|
||||||
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
|
||||||
|
return PlacementModeMapping, &diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportSelectorValue(spec *importDeclarationSpec, field string, node *document.Node, selectorPath Path) *Diagnostic {
|
||||||
switch spec.selector {
|
switch spec.selector {
|
||||||
case importSelectorPath, importSelectorDirectory:
|
case importSelectorPath, importSelectorDirectory:
|
||||||
values, origins, ok, badIndex := parseStringSet(selectorEntry.Value)
|
values, origins, ok, badIndex := parseStringSet(node)
|
||||||
if !ok {
|
if !ok {
|
||||||
name := selectorEntry.Key
|
diagnostic := stringSetDiagnostic(node, selectorPath, field, badIndex)
|
||||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(selectorEntry.Value, selectorPath, name, badIndex)}
|
return &diagnostic
|
||||||
}
|
}
|
||||||
for index, value := range values {
|
for index, value := range values {
|
||||||
if !validImportPath(value) {
|
if !validImportPath(value) {
|
||||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
badPath, badNode := listMemberSite(node, selectorPath, origins[index])
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)}
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, badPath, field+" contains an invalid source path", badNode)
|
||||||
|
return &diagnostic
|
||||||
}
|
}
|
||||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
badPath, badNode := listMemberSite(node, selectorPath, origins[index])
|
||||||
spec.selectorSites = append(spec.selectorSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)})
|
spec.selectorSites = append(spec.selectorSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)})
|
||||||
}
|
}
|
||||||
spec.paths = values
|
spec.paths = values
|
||||||
case importSelectorGlob:
|
case importSelectorGlob:
|
||||||
if selectorEntry.Value.Kind != document.String || !validImportPath(selectorEntry.Value.Text) {
|
if node.Kind != document.String || !validImportPath(node.Text) {
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", selectorEntry.Value)}
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", node)
|
||||||
|
return &diagnostic
|
||||||
}
|
}
|
||||||
segments, ok := validGlobPattern(selectorEntry.Value.Text)
|
segments, ok := validGlobPattern(node.Text)
|
||||||
if !ok {
|
if !ok {
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", selectorEntry.Value)}
|
diagnostic := nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", node)
|
||||||
|
return &diagnostic
|
||||||
}
|
}
|
||||||
spec.glob, spec.globSegments = selectorEntry.Value.Text, segments
|
spec.glob, spec.globSegments = node.Text, segments
|
||||||
}
|
}
|
||||||
return spec, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectorForField(field string) importSelector {
|
func selectorForField(field string) importSelector {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ func TestParseImportDeclarationSpecNormalizesPresenceAndPlacement(t *testing.T)
|
|||||||
if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" {
|
if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" {
|
||||||
t.Fatalf("path selector = %#v", first)
|
t.Fatalf("path selector = %#v", first)
|
||||||
}
|
}
|
||||||
if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != placementModeSequenceItem {
|
if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != PlacementModeSequenceItem {
|
||||||
t.Fatalf("explicit declaration values = %#v", first)
|
t.Fatalf("explicit declaration values = %#v", first)
|
||||||
}
|
}
|
||||||
if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" {
|
if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" {
|
||||||
t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String())
|
t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String())
|
||||||
}
|
}
|
||||||
second := specs[1]
|
second := specs[1]
|
||||||
if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || !second.excludeSet || second.exclude == nil {
|
if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || second.exclude == nil {
|
||||||
t.Fatalf("empty list presence was lost: %#v", second)
|
t.Fatalf("empty list presence was lost: %#v", second)
|
||||||
}
|
}
|
||||||
third := specs[2]
|
third := specs[2]
|
||||||
@@ -123,13 +123,13 @@ func TestParseImportDeclarationSpecValidatesDepthSyntaxAndPolicy(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics)
|
t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
_, diagnostics = resolveImportPolicy(specs[0], importDefaults{})
|
_, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" {
|
if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" {
|
||||||
t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics)
|
t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
recursive := true
|
recursive := true
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{recursive: &recursive})
|
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{Recursive: &recursive})
|
||||||
if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet {
|
if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet {
|
||||||
t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics)
|
t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], importDefaults{})
|
omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], ImportDefaults{})
|
||||||
if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil {
|
if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil {
|
||||||
t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics)
|
t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -159,12 +159,12 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
|||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
appRequired, appRecursive, appFollow, appHidden := true, true, true, true
|
appRequired, appRecursive, appFollow, appHidden := true, true, true, true
|
||||||
defaults := importDefaults{
|
defaults := ImportDefaults{
|
||||||
required: &appRequired,
|
Required: &appRequired,
|
||||||
recursive: &appRecursive,
|
Recursive: &appRecursive,
|
||||||
follow: &appFollow,
|
Follow: &appFollow,
|
||||||
hidden: &appHidden,
|
Hidden: &appHidden,
|
||||||
pattern: []string{"*.yaml"},
|
Pattern: []string{"*.yaml"},
|
||||||
}
|
}
|
||||||
policy, diagnostics := resolveImportPolicy(specs[0], defaults)
|
policy, diagnostics := resolveImportPolicy(specs[0], defaults)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -188,16 +188,24 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
snapshot := snapshotImportDefaults(defaults)
|
snapshot := snapshotImportDefaults(defaults)
|
||||||
*defaults.required = false
|
*defaults.Required = false
|
||||||
defaults.pattern[0] = "*.toml"
|
*defaults.Follow = false
|
||||||
if snapshot.required == nil || !*snapshot.required || snapshot.pattern[0] != "*.yaml" {
|
*defaults.Hidden = false
|
||||||
|
*defaults.Recursive = false
|
||||||
|
defaults.Pattern[0] = "*.toml"
|
||||||
|
if snapshot.Required == nil || !*snapshot.Required || snapshot.Follow == nil || !*snapshot.Follow ||
|
||||||
|
snapshot.Hidden == nil || !*snapshot.Hidden || snapshot.Recursive == nil || !*snapshot.Recursive || snapshot.Pattern[0] != "*.yaml" {
|
||||||
t.Fatalf("snapshot shares mutable inputs: %#v", snapshot)
|
t.Fatalf("snapshot shares mutable inputs: %#v", snapshot)
|
||||||
}
|
}
|
||||||
empty := snapshotImportDefaults(importDefaults{pattern: []string{}})
|
omittedSnapshot := snapshotImportDefaults(ImportDefaults{})
|
||||||
if empty.pattern == nil || len(empty.pattern) != 0 {
|
if omittedSnapshot.Required != nil || omittedSnapshot.Follow != nil || omittedSnapshot.Hidden != nil || omittedSnapshot.Recursive != nil {
|
||||||
|
t.Fatal("snapshot changed omitted booleans into explicit values")
|
||||||
|
}
|
||||||
|
empty := snapshotImportDefaults(ImportDefaults{Pattern: []string{}})
|
||||||
|
if empty.Pattern == nil || len(empty.Pattern) != 0 {
|
||||||
t.Fatalf("snapshot lost explicit empty pattern: %#v", empty)
|
t.Fatalf("snapshot lost explicit empty pattern: %#v", empty)
|
||||||
}
|
}
|
||||||
if snapshotImportDefaults(importDefaults{}).pattern != nil {
|
if snapshotImportDefaults(ImportDefaults{}).Pattern != nil {
|
||||||
t.Fatal("snapshot changed omitted pattern into explicit empty")
|
t.Fatal("snapshot changed omitted pattern into explicit empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +215,7 @@ func TestInvalidImportDefaultPatternsFailBeforeReadingSource(t *testing.T) {
|
|||||||
|
|
||||||
reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")}
|
reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")}
|
||||||
_, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{
|
_, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{
|
||||||
importDefaults: importDefaults{pattern: []string{"["}},
|
ImportDefaults: ImportDefaults{Pattern: []string{"["}},
|
||||||
})
|
})
|
||||||
var loadErr *LoadError
|
var loadErr *LoadError
|
||||||
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
@@ -224,11 +232,11 @@ func parseImportSpecsYAML(t *testing.T, declarations string) ([]importDeclaratio
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, diagnostics
|
return nil, diagnostics
|
||||||
}
|
}
|
||||||
index := documentEntryIndex(parsed.document.Mapping, directiveKey)
|
extracted, diagnostics := extractRootDirectives(parsed.document, rootDirectiveContextSource)
|
||||||
if index < 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal("missing root directive in test fixture")
|
return nil, diagnostics
|
||||||
}
|
}
|
||||||
return parseImportSpecificationDirective(parsed.document.Mapping[index].Value, Keys(directiveKey))
|
return extracted.imports, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type countingImportSpecReader struct {
|
type countingImportSpecReader struct {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -113,14 +113,22 @@ tasks: [{id: z-parent}]
|
|||||||
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
|
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
|
||||||
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
|
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
|
||||||
|
|
||||||
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
|
sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}})
|
||||||
Imports: true,
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
|
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||||
})
|
directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
}, acquisitionPolicy{
|
||||||
|
sequencePolicy: sequencePolicy,
|
||||||
|
importLimits: defaultImportLimits(),
|
||||||
|
}, nil)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assemble imports: %#v", diagnostics)
|
t.Fatalf("assemble imports: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
tasks, ok := documentAtPath(input.document, Keys("tasks"))
|
composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose acquired imports: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
tasks, ok := documentAtPath(composition.root, Keys("tasks"))
|
||||||
if !ok || tasks.Kind != document.Sequence {
|
if !ok || tasks.Kind != document.Sequence {
|
||||||
t.Fatalf("tasks document = %#v, %v", tasks, ok)
|
t.Fatalf("tasks document = %#v, %v", tasks, ok)
|
||||||
}
|
}
|
||||||
@@ -153,7 +161,12 @@ func TestAcquiredContextsUseSelectedFileWhenSourceRecordsAreReordered(t *testing
|
|||||||
{ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile},
|
{ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile},
|
||||||
{ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile},
|
{ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile},
|
||||||
}
|
}
|
||||||
contexts, diagnostic := contextsForAcquiredSources(File(mainPath), nil, nil, sources)
|
mainInfo := &SourceInfo{Path: canonicalTestPath(t, mainPath)}
|
||||||
|
resolved, diagnostic := resolveFilesystemContext(File(mainPath), nil, parsedSource{source: mainInfo}, nil)
|
||||||
|
if diagnostic != nil {
|
||||||
|
t.Fatalf("resolve main context: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
contexts, diagnostic := contextsForResolvedSources(&resolved, sources)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
t.Fatalf("source contexts: %#v", diagnostic)
|
t.Fatalf("source contexts: %#v", diagnostic)
|
||||||
}
|
}
|
||||||
@@ -398,9 +411,9 @@ label: helper
|
|||||||
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
||||||
|
|
||||||
loaded, err := LoadLayered(
|
loaded, err := LoadLayered(
|
||||||
Input{Source: NoSource()}, testSecretBinding(),
|
Input{Source: NoSource(), Imports: true}, testSecretBinding(),
|
||||||
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
|
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem,
|
||||||
}}}}},
|
}}}}},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -490,7 +503,7 @@ func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testi
|
|||||||
t.Fatalf("config = %#v", result.Config)
|
t.Fatalf("config = %#v", result.Config)
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != placementModeSequenceItem || sources[2].placementMode != placementModeMapping || sources[2].Destination.String() != "/other" {
|
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != PlacementModeSequenceItem || sources[2].placementMode != PlacementModeMapping || sources[2].Destination.String() != "/other" {
|
||||||
t.Fatalf("source placement evidence = %#v", sources)
|
t.Fatalf("source placement evidence = %#v", sources)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,7 +521,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
|
||||||
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
|
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
|
||||||
result, err := testLoad[config](File(mainPath), Options[config]{
|
result, err := testLoad[config](File(mainPath), Options[config]{
|
||||||
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
|
Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load main item: %v", err)
|
t.Fatalf("load main item: %v", err)
|
||||||
@@ -517,7 +530,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
|
|||||||
t.Fatalf("main item = %#v", result.Config.Items)
|
t.Fatalf("main item = %#v", result.Config.Items)
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != placementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != placementModeMapping {
|
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != PlacementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != PlacementModeMapping {
|
||||||
t.Fatalf("main item sources = %#v", sources)
|
t.Fatalf("main item sources = %#v", sources)
|
||||||
}
|
}
|
||||||
if sources[1].Path != canonicalTestPath(t, helperPath) {
|
if sources[1].Path != canonicalTestPath(t, helperPath) {
|
||||||
@@ -536,7 +549,7 @@ func TestProjectAcquiredDestinationsUsesMappedSequenceItemIndex(t *testing.T) {
|
|||||||
items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}},
|
items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}},
|
||||||
}}}
|
}}}
|
||||||
scope := acquisitionScope{contributions: []acquiredContribution{{
|
scope := acquisitionScope{contributions: []acquiredContribution{{
|
||||||
source: SourceInfo{ID: "outer-source", placementMode: placementModeSequenceItem},
|
source: SourceInfo{ID: "outer-source", placementMode: PlacementModeSequenceItem},
|
||||||
destination: Keys("records"), nested: child,
|
destination: Keys("records"), nested: child,
|
||||||
}}}
|
}}}
|
||||||
sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}}
|
sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}}
|
||||||
@@ -617,9 +630,10 @@ id: item
|
|||||||
`)
|
`)
|
||||||
helperPath := writeImportFile(t, directory, "helper.yml", "value: acquired\n")
|
helperPath := writeImportFile(t, directory, "helper.yml", "value: acquired\n")
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
input, diagnostics := assembleBaseInputTracked(File(mainPath), Options[struct{}]{
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
Imports: true, Filesystem: &FilesystemContext{Root: directory}, deferAssembly: true,
|
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: directory}},
|
||||||
}, Path{}, accounting, nil, nil)
|
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
}, acquisitionPolicy{importLimits: defaultImportLimits()}, accounting)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("acquire input: %#v", diagnostics)
|
t.Fatalf("acquire input: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -674,12 +688,20 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
|
|||||||
`)
|
`)
|
||||||
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
|
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
|
||||||
writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
|
writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
|
||||||
input, diagnostics := assembleBaseInput(File(mainPath), Options[config]{Imports: true})
|
sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}})
|
||||||
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
|
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||||
|
directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assemble item import: %#v", diagnostics)
|
t.Fatalf("assemble item import: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
imported, diagnostics := compositionForInputWithPolicy(input, sequencePolicy)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose acquired item import: %#v", diagnostics)
|
||||||
|
}
|
||||||
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
|
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
|
||||||
composed, diagnostics := composeDocumentsWithPolicy(mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}), authored, input.document)
|
composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy, authored, imported.root)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -738,7 +760,7 @@ func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
|||||||
- glob: records/*.yml
|
- glob: records/*.yml
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
`)
|
`)
|
||||||
if len(declarations) != 2 || declarations[0].placement != placementModeMapping || declarations[1].placement != placementModeSequenceItem {
|
if len(declarations) != 2 || declarations[0].placement != PlacementModeMapping || declarations[1].placement != PlacementModeSequenceItem {
|
||||||
t.Fatalf("declaration placements = %#v", declarations)
|
t.Fatalf("declaration placements = %#v", declarations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -862,7 +884,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||||
writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n")
|
writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n")
|
||||||
_, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
_, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||||
Imports: true, importDefaults: importDefaults{required: &required},
|
Imports: true, ImportDefaults: ImportDefaults{Required: &required},
|
||||||
})
|
})
|
||||||
var loadError *LoadError
|
var loadError *LoadError
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
@@ -874,7 +896,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
|
|||||||
_, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{
|
_, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{
|
||||||
main: loadInput{source: File(basePath)},
|
main: loadInput{source: File(basePath)},
|
||||||
underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}},
|
underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}},
|
||||||
importDefaults: importDefaults{required: &required},
|
importDefaults: ImportDefaults{Required: &required},
|
||||||
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
t.Fatalf("grouped underlay required default error = %#v", err)
|
t.Fatalf("grouped underlay required default error = %#v", err)
|
||||||
@@ -951,7 +973,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
|
|||||||
itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n")
|
itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n")
|
||||||
helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n")
|
helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n")
|
||||||
|
|
||||||
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
|
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||||
|
directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assemble input: %#v", diagnostics)
|
t.Fatalf("assemble input: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -983,10 +1008,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.nested == nil {
|
if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.pendingItem == nil {
|
||||||
t.Fatalf("outer item contribution = %#v", item)
|
t.Fatalf("outer item contribution = %#v", item)
|
||||||
}
|
}
|
||||||
itemScope := item.nested.acquisition[0]
|
itemScope := item.pendingItem
|
||||||
if itemScope.owner != item.identity || len(itemScope.contributions) != 2 {
|
if itemScope.owner != item.identity || len(itemScope.contributions) != 2 {
|
||||||
t.Fatalf("nested item scope = %#v", itemScope)
|
t.Fatalf("nested item scope = %#v", itemScope)
|
||||||
}
|
}
|
||||||
@@ -1082,7 +1107,7 @@ id: item
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
|
||||||
writeImportFile(t, directory, "helper.yml", "id: helper\n")
|
writeImportFile(t, directory, "helper.yml", "id: helper\n")
|
||||||
options := Options[importTestConfig]{
|
options := Options[importTestConfig]{
|
||||||
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
|
Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
|
||||||
}
|
}
|
||||||
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
|
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -1092,6 +1117,70 @@ id: item
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBroadItemScopesKeepHelperOriginsAndDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
const count = 32
|
||||||
|
var main strings.Builder
|
||||||
|
main.WriteString("configkit:\n imports:\n")
|
||||||
|
for index := count - 1; index >= 0; index-- {
|
||||||
|
name := strconv.Itoa(100 + index)
|
||||||
|
main.WriteString(" - path: item-" + name + ".yml\n destination: [items]\n placement: sequence-item\n")
|
||||||
|
writeImportFile(t, directory, "item-"+name+".yml", "configkit: {imports: [{path: helper-"+name+".yml}]}\nid: item-"+name+"\n")
|
||||||
|
writeImportFile(t, directory, "helper-"+name+".yml", "value: helper-"+name+"\n")
|
||||||
|
}
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", main.String())
|
||||||
|
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||||
|
}}}
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
t.Run(strconv.FormatBool(layered), func(t *testing.T) {
|
||||||
|
var loaded *Loaded[config]
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
loaded, err = LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[config]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
loaded, err = Load(File(mainPath), testSecretBinding(), Options[config]{Imports: true, Definition: definition})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(result.Config.Items) != count || len(sources) != 2*count+1 {
|
||||||
|
t.Fatalf("item/source counts = %d/%d", len(result.Config.Items), len(sources))
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
for index, record := range result.Config.Items {
|
||||||
|
name := strconv.Itoa(100 + index)
|
||||||
|
if record.ID != "item-"+name || record.Value != "helper-"+name {
|
||||||
|
t.Fatalf("item %d = %#v", index, record)
|
||||||
|
}
|
||||||
|
// Acquisition remains authored (descending); final item order is
|
||||||
|
// dependency-ready (ascending), with helper evidence reprojected.
|
||||||
|
helper := sources[2*(count-index)]
|
||||||
|
field, found := metadata.Lookup(Keys("items").Index(index).Key("value"))
|
||||||
|
if !found || len(field.Origin.Locations) != 1 || field.Origin.Locations[0].SourceID != helper.ID ||
|
||||||
|
helper.Path != canonicalTestPath(t, filepath.Join(directory, "helper-"+name+".yml")) ||
|
||||||
|
helper.Destination.String() != Keys("items").Index(index).String() {
|
||||||
|
t.Fatalf("item %d helper evidence = %#v, %#v", index, field, helper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
@@ -1104,12 +1193,19 @@ func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
|||||||
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n")
|
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n")
|
||||||
writeImportFile(t, directory, "a-dependency.yml", "a: true\n")
|
writeImportFile(t, directory, "a-dependency.yml", "a: true\n")
|
||||||
writeImportFile(t, directory, "c-independent.yml", "c: true\n")
|
writeImportFile(t, directory, "c-independent.yml", "c: true\n")
|
||||||
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
|
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||||
|
directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assemble item scope: %#v", diagnostics)
|
t.Fatalf("assemble item scope: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose acquired item scope: %#v", diagnostics)
|
||||||
|
}
|
||||||
var nested *composedDocument
|
var nested *composedDocument
|
||||||
for _, contribution := range input.composition.acquisition[0].contributions {
|
for _, contribution := range composition.acquisition[0].contributions {
|
||||||
if contribution.role == importRoleItem {
|
if contribution.role == importRoleItem {
|
||||||
nested = contribution.nested
|
nested = contribution.nested
|
||||||
break
|
break
|
||||||
@@ -1268,7 +1364,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
|
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, test.limits)
|
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||||
|
_, _, diagnostics := testAcquireImportWithLimits(request, test.limits)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("budget diagnostics = %#v", diagnostics)
|
t.Fatalf("budget diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1375,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||||
main = parseImportMain(t, mainPath)
|
main = parseImportMain(t, mainPath)
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||||
|
_, _, diagnostics := testAcquireImportWithLimits(request, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("depth diagnostics = %#v", diagnostics)
|
t.Fatalf("depth diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -1312,15 +1410,17 @@ func TestImportDepthBudgetCountsSelectedEdges(t *testing.T) {
|
|||||||
writeImportFile(t, directory, "parts/next.yml", "next: true\n")
|
writeImportFile(t, directory, "parts/next.yml", "next: true\n")
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
limits.depth = 1
|
limits.depth = 1
|
||||||
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, parseImportMain(t, mainPath), nil, limits)
|
main := parseImportMain(t, mainPath)
|
||||||
|
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||||
|
acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||||
if test.code != "" {
|
if test.code != "" {
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
||||||
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
|
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
|
||||||
t.Fatalf("depth-boundary composition: sources=%#v diagnostics=%#v", sources, diagnostics)
|
t.Fatalf("depth-boundary acquisition: sources=%#v diagnostics=%#v", sources, diagnostics)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1342,9 +1442,10 @@ func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
|||||||
bytes: main.bytes + int64(len(data)),
|
bytes: main.bytes + int64(len(data)),
|
||||||
nodes: countInputNodes(main.document) + countInputNodes(imported.document),
|
nodes: countInputNodes(main.document) + countInputNodes(imported.document),
|
||||||
}
|
}
|
||||||
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||||
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
|
||||||
|
t.Fatalf("exact-limit acquisition = (%#v, %#v, %#v)", acquired, sources, diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,6 +1502,50 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testImportRequest(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, processing *sourceVersionProcessingResult) importAcquisitionRequest {
|
||||||
|
return importAcquisitionRequest{
|
||||||
|
input: inputAcquisitionRequest{
|
||||||
|
input: loadInput{
|
||||||
|
source: source, imports: true, filesystem: context,
|
||||||
|
destination: destination.clone(), placementMode: main.source.placementMode,
|
||||||
|
},
|
||||||
|
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
protectionRule: rule,
|
||||||
|
},
|
||||||
|
main: main, processing: processing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||||
|
if accounting == nil {
|
||||||
|
accounting = &importAccounting{}
|
||||||
|
}
|
||||||
|
state := &acquisitionState{accounting: accounting}
|
||||||
|
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)}
|
||||||
|
return acquireInput(request, policy, state, tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcquireImport(request importAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
composition, sources, _, diagnostics := acquireImportedInput(request, &policy, state, tier)
|
||||||
|
return composition, sources, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func testComposeAcquiredImport(request importAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, acquired *composedDocument, sources []SourceInfo) (*composedDocument, []Diagnostic) {
|
||||||
|
input := assembledInput{
|
||||||
|
parsed: request.main, composition: acquired, acquisition: cloneAcquisitionScopes(acquired.acquisition),
|
||||||
|
sources: sources, versionState: state.versionState, accounting: state.accounting,
|
||||||
|
}
|
||||||
|
return composeAcquiredInputWithPolicies(input, collectionPolicyScope{}, policy.sequencePolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcquireImportWithLimits(request importAcquisitionRequest, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: &importAccounting{}}, &tierAcquisitionState{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcquireImportWithAccounting(request importAcquisitionRequest, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, &tierAcquisitionState{})
|
||||||
|
}
|
||||||
|
|
||||||
func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec {
|
func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
declarations, diagnostics := parseImportSpecificationYAML(input)
|
declarations, diagnostics := parseImportSpecificationYAML(input)
|
||||||
|
|||||||
@@ -114,26 +114,7 @@ func cloneInheritanceOperations(operations []inheritanceOperation) []inheritance
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inheritancePolicyScopeAtPath(scope collectionPolicyScope, prefix Path) collectionPolicyScope {
|
func inheritancePolicyScopeAtPath(scope collectionPolicyScope, prefix Path) collectionPolicyScope {
|
||||||
localized := collectionPolicyScope{}
|
return newInheritancePolicyIndex(scope).localize(prefix)
|
||||||
for _, entry := range scope.collections {
|
|
||||||
if relative, inside := relativeHistoryPath(prefix, entry.destination); inside {
|
|
||||||
localized.set(relative, cloneCollectionPolicyForInheritance(entry.policy))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
relative, inside := relativeHistoryPath(entry.destination, prefix)
|
|
||||||
if !inside || len(relative.segments) == 0 || relative.segments[0].kind != pathSegmentIndex {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
record, found := entry.policy.recordPolicyAt(relative.segments[0].index)
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remainder := Path{segments: append([]pathSegment(nil), relative.segments[1:]...)}
|
|
||||||
for _, nested := range inheritancePolicyScopeAtPath(record.scope, remainder).collections {
|
|
||||||
localized.set(nested.destination, cloneCollectionPolicyForInheritance(nested.policy))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return localized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix Path) []projectedCollectionPolicy {
|
func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix Path) []projectedCollectionPolicy {
|
||||||
@@ -148,15 +129,11 @@ func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix
|
|||||||
return projected
|
return projected
|
||||||
}
|
}
|
||||||
|
|
||||||
// A replacement retains the displaced source/derivation record while the
|
// A replacement retains the before and after values for history accounting.
|
||||||
// effective origin belongs to the child. Value trees are borrowed immutable
|
// The trees are borrowed immutable inputs or detached templates.
|
||||||
// inputs or detached templates and are budgeted with retained history later.
|
|
||||||
type inheritanceReplacement struct {
|
type inheritanceReplacement struct {
|
||||||
path Path
|
before *document.Node
|
||||||
before *document.Node
|
after *document.Node
|
||||||
after *document.Node
|
|
||||||
previous []displacedDefinition
|
|
||||||
effective Origin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A link represents one shared relationship edge. Each contributor retains
|
// A link represents one shared relationship edge. Each contributor retains
|
||||||
@@ -192,11 +169,6 @@ func inheritanceDirective(node *document.Node) (document.Entry, bool) {
|
|||||||
return document.Entry{}, false
|
return document.Entry{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func inheritanceDirectiveLocation(node *document.Node) Location {
|
|
||||||
entry, _ := inheritanceDirective(node)
|
|
||||||
return publicLocation(entry.Value.Location)
|
|
||||||
}
|
|
||||||
|
|
||||||
func withoutInheritanceDirective(node *document.Node) *document.Node {
|
func withoutInheritanceDirective(node *document.Node) *document.Node {
|
||||||
payload := *node
|
payload := *node
|
||||||
payload.Mapping = make([]document.Entry, 0, len(node.Mapping))
|
payload.Mapping = make([]document.Entry, 0, len(node.Mapping))
|
||||||
|
|||||||
@@ -103,15 +103,19 @@ func mergeInheritanceCollectionPolicies(
|
|||||||
for key, entry := range child.collections {
|
for key, entry := range child.collections {
|
||||||
paths[key] = entry.destination.clone()
|
paths[key] = entry.destination.clone()
|
||||||
}
|
}
|
||||||
|
var lookup *documentLookup
|
||||||
|
if len(paths) != 0 {
|
||||||
|
lookup = buildDocumentLookup(parentValue, childValue)
|
||||||
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
relative, inside := relativeHistoryPath(parentRootPath, path)
|
relative, inside := relativeHistoryPath(parentRootPath, path)
|
||||||
var retained, incoming *document.Node
|
var retained, incoming *document.Node
|
||||||
if inside {
|
if inside {
|
||||||
if !replaced.contains(path, false) {
|
if !replaced.contains(path, false) {
|
||||||
retained, _ = documentAtPath(parentValue, relative)
|
retained, _ = documentAtPathWithLookup(parentValue, relative, lookup)
|
||||||
}
|
}
|
||||||
incoming, _ = documentAtPath(childValue, relative)
|
incoming, _ = documentAtPathWithLookup(childValue, relative, lookup)
|
||||||
}
|
}
|
||||||
resolution, lower, _, active, issues := inheritanceCollectionResolution(path, parent, child, applicationPolicy, incoming)
|
resolution, lower, _, active, issues := inheritanceCollectionResolution(path, parent, child, applicationPolicy, incoming)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
@@ -224,19 +228,17 @@ func applyInheritanceSequencePolicy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyInheritanceSequenceOrigins(
|
func applyInheritanceSequenceOrigins(
|
||||||
result *inheritanceScope,
|
|
||||||
origins, incomingOrigins map[string]Origin,
|
origins, incomingOrigins map[string]Origin,
|
||||||
path Path,
|
path Path,
|
||||||
lower, higher *document.Node,
|
lower, higher *document.Node,
|
||||||
combined sequenceCombinationResult,
|
combined sequenceCombinationResult,
|
||||||
operation sequenceMode,
|
operation sequenceMode,
|
||||||
) {
|
) []inheritanceReplacement {
|
||||||
|
var replacements []inheritanceReplacement
|
||||||
if operation == sequenceModeReplace {
|
if operation == sequenceModeReplace {
|
||||||
if lower != nil {
|
if lower != nil {
|
||||||
displaced := displacedOrigins(lower, origins, path)
|
replacements = append(replacements, inheritanceReplacement{
|
||||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
before: lower, after: higher,
|
||||||
path: path.clone(), before: lower, after: higher,
|
|
||||||
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
|
|
||||||
})
|
})
|
||||||
deleteOriginSubtree(origins, path, lower)
|
deleteOriginSubtree(origins, path, lower)
|
||||||
}
|
}
|
||||||
@@ -249,7 +251,7 @@ func applyInheritanceSequenceOrigins(
|
|||||||
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
|
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return replacements
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
|
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
|
||||||
@@ -261,12 +263,10 @@ func applyInheritanceSequenceOrigins(
|
|||||||
lowerPath := path.Index(replacement.lowerIndex)
|
lowerPath := path.Index(replacement.lowerIndex)
|
||||||
higherPath := path.Index(replacement.higherIndex)
|
higherPath := path.Index(replacement.higherIndex)
|
||||||
outputPath := path.Index(replacement.outputIndex)
|
outputPath := path.Index(replacement.outputIndex)
|
||||||
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
|
|
||||||
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
|
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
|
||||||
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath)
|
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath)
|
||||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
replacements = append(replacements, inheritanceReplacement{
|
||||||
path: outputPath, before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
|
before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
|
||||||
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, projection := range combined.higherItems {
|
for _, projection := range combined.higherItems {
|
||||||
@@ -274,6 +274,7 @@ func applyInheritanceSequenceOrigins(
|
|||||||
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
|
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return replacements
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic {
|
func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic {
|
||||||
|
|||||||
117
internal/engine/inheritance_combine.go
Normal file
117
internal/engine/inheritance_combine.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
type inheritanceTemplateResult struct {
|
||||||
|
value *document.Node
|
||||||
|
origins map[string]Origin
|
||||||
|
collectionPolicies collectionPolicyScope
|
||||||
|
policyTemplates []collectionPolicyScope
|
||||||
|
controls []inheritanceControl
|
||||||
|
transfers []inheritanceTransfer
|
||||||
|
replacements []inheritanceReplacement
|
||||||
|
operations []inheritanceOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
func combineInheritanceTemplate(
|
||||||
|
template inheritanceExecutionTemplate,
|
||||||
|
child preparedInheritanceChild,
|
||||||
|
parent *preparedInheritanceParent,
|
||||||
|
parentOrigins map[string]Origin,
|
||||||
|
applicationPolicy sequencePolicy,
|
||||||
|
) (inheritanceTemplateResult, []Diagnostic) {
|
||||||
|
result := inheritanceTemplateResult{}
|
||||||
|
if child.control != nil {
|
||||||
|
result.controls = append(result.controls, *child.control)
|
||||||
|
result.operations = append(result.operations, inheritanceOperation{
|
||||||
|
control: child.control, controlOrigin: child.controlOrigin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if parent == nil {
|
||||||
|
result.value = document.Clone(child.payload)
|
||||||
|
result.origins = child.origins
|
||||||
|
result.collectionPolicies = cloneCollectionPolicyScopeForInheritance(child.policyTemplates)
|
||||||
|
result.policyTemplates = []collectionPolicyScope{child.policyTemplates}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedPolicyScope, diagnostics := mergeInheritanceCollectionPolicies(
|
||||||
|
parent.parentPolicyScope, parent.childPolicyScope,
|
||||||
|
template.path, parent.payload, child.payload,
|
||||||
|
applicationPolicy, parent.replaced,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return inheritanceTemplateResult{}, diagnostics
|
||||||
|
}
|
||||||
|
mergedOrigins := transferredInheritanceOrigins(parentOrigins, parent.transfer.from, template.path, parent.transfer)
|
||||||
|
mergeInheritanceOrigin(mergedOrigins, template.path, child.origins[template.path.String()])
|
||||||
|
var sequenceContributions []sequenceHistoryContribution
|
||||||
|
var combinationDiagnostics []Diagnostic
|
||||||
|
mergeRecursiveMappings(parent.payload, child.payload, template.path, new(mappingIndex), recursiveMergeHooks{
|
||||||
|
added: func(childPath Path, incoming *document.Node) {
|
||||||
|
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
|
||||||
|
},
|
||||||
|
combined: func(childPath Path, _ *document.Node) {
|
||||||
|
mergeInheritanceOrigin(mergedOrigins, childPath, child.origins[childPath.String()])
|
||||||
|
},
|
||||||
|
replaced: func(childPath Path, existing, incoming *document.Node) {
|
||||||
|
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||||
|
before: existing, after: incoming,
|
||||||
|
})
|
||||||
|
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
||||||
|
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
|
||||||
|
},
|
||||||
|
merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) {
|
||||||
|
resolution, _, _, active, issues := inheritanceCollectionResolution(
|
||||||
|
childPath, parent.parentPolicyScope, parent.childPolicyScope, applicationPolicy, incoming,
|
||||||
|
)
|
||||||
|
if len(issues) != 0 {
|
||||||
|
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||||
|
return existing, false
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
lowerValue := existing
|
||||||
|
combined, problems := combineSequence(lowerValue, incoming, resolution, sequenceCombinationContext{destination: childPath})
|
||||||
|
if len(problems) != 0 {
|
||||||
|
combinationDiagnostics = append(combinationDiagnostics, problems...)
|
||||||
|
return existing, false
|
||||||
|
}
|
||||||
|
policy := applyInheritanceSequencePolicy(
|
||||||
|
childPath, combined, resolution, parent.parentPolicyScope, parent.childPolicyScope,
|
||||||
|
)
|
||||||
|
mergedPolicyScope.set(childPath, policy)
|
||||||
|
result.replacements = append(result.replacements, applyInheritanceSequenceOrigins(
|
||||||
|
mergedOrigins, child.origins, childPath, lowerValue, incoming, combined, resolution.operation,
|
||||||
|
)...)
|
||||||
|
sequenceContributions = append(sequenceContributions, sequenceHistoryContributionForCombination(
|
||||||
|
childPath, resolution.operation, incoming, combined,
|
||||||
|
))
|
||||||
|
return combined.value, true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
preserveInheritanceIdentityFields(parent.payload, child.payload, template.identityFields)
|
||||||
|
if len(combinationDiagnostics) != 0 {
|
||||||
|
return inheritanceTemplateResult{}, canonicalDiagnostics(combinationDiagnostics)
|
||||||
|
}
|
||||||
|
if problems := validateRegisteredInheritanceSequences(parent.payload, template.path, applicationPolicy); len(problems) != 0 {
|
||||||
|
return inheritanceTemplateResult{}, problems
|
||||||
|
}
|
||||||
|
parent.payload.Location = template.entry.Value.Location
|
||||||
|
result.value = parent.payload
|
||||||
|
result.origins = mergedOrigins
|
||||||
|
result.collectionPolicies = inheritancePolicyScopeAtPath(mergedPolicyScope, template.path)
|
||||||
|
result.policyTemplates = parent.effectivePolicyTemplates
|
||||||
|
result.transfers = append(result.transfers, parent.transfer)
|
||||||
|
result.operations = append(result.operations, inheritanceOperation{
|
||||||
|
transfer: &parent.transfer, parent: parent.historyParent, effective: parent.payload,
|
||||||
|
parentOrigins: parentOrigins, effectiveOrigins: mergedOrigins,
|
||||||
|
parentPolicies: cloneInheritancePolicyTemplates(parent.policyTemplates),
|
||||||
|
templatePolicies: cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
|
||||||
|
effectivePolicies: cloneInheritancePolicyTemplates(parent.effectivePolicyTemplates),
|
||||||
|
sequences: cloneSequenceHistoryContributions(sequenceContributions),
|
||||||
|
identityFields: cloneStrings(template.identityFields),
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
55
internal/engine/inheritance_finalize.go
Normal file
55
internal/engine/inheritance_finalize.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
func chargeInheritanceTemplateHistory(counter *retainedNodeCounter, result inheritanceTemplateResult, template inheritanceExecutionTemplate) *Diagnostic {
|
||||||
|
for _, operation := range result.operations {
|
||||||
|
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
||||||
|
diagnostic := inheritanceHistoryBudgetDiagnostic(template.path, template.entry.Value.Location)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, replacement := range result.replacements {
|
||||||
|
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
||||||
|
diagnostic := inheritanceHistoryBudgetDiagnostic(template.path, template.entry.Value.Location)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeInheritanceScope(
|
||||||
|
adapter inheritanceScopeAdapter,
|
||||||
|
result *inheritanceScope,
|
||||||
|
templates []inheritanceExecutionTemplate,
|
||||||
|
resolved []*document.Node,
|
||||||
|
resolvedOrigins []map[string]Origin,
|
||||||
|
resolvedPolicies []collectionPolicyScope,
|
||||||
|
inputOrigins map[string]Origin,
|
||||||
|
limits projectionLimits,
|
||||||
|
) []Diagnostic {
|
||||||
|
root := adapter.rebuild(resolved)
|
||||||
|
result.collectionPolicies = collectionPolicyScope{}
|
||||||
|
result.origins = directInheritanceOrigins(&document.Node{Kind: root.Kind, Location: root.Location}, adapter.scopePath(), inputOrigins)
|
||||||
|
policyIndex := newInheritancePolicyIndex(adapter.collectionPolicies())
|
||||||
|
for _, slot := range adapter.slots() {
|
||||||
|
if slot.hasTemplate {
|
||||||
|
index := slot.template
|
||||||
|
projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], templates[index].path)
|
||||||
|
for pointer, origin := range resolvedOrigins[index] {
|
||||||
|
result.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localPolicies := policyIndex.localize(slot.path)
|
||||||
|
projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, slot.path)
|
||||||
|
for pointer, origin := range directInheritanceOrigins(slot.value, slot.path, inputOrigins) {
|
||||||
|
result.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.root = root
|
||||||
|
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
||||||
|
return []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -74,7 +74,6 @@ func applyInheritanceMetadata(metadata *Metadata, raw *document.Node, rawOrigins
|
|||||||
})
|
})
|
||||||
applyOverlayMetadata(&sourceView, overlays)
|
applyOverlayMetadata(&sourceView, overlays)
|
||||||
metadata.history = sourceView.history
|
metadata.history = sourceView.history
|
||||||
metadata.inheritance = cloneInheritanceOperations(expansion.operations)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change {
|
func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocume
|
|||||||
}
|
}
|
||||||
metadata := metadataFromDecoded(decoded)
|
metadata := metadataFromDecoded(decoded)
|
||||||
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
|
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
|
||||||
|
metadata.inheritance = cloneInheritanceOperations(expanded.operations)
|
||||||
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
|
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
|
||||||
return &Inspection{
|
return &Inspection{
|
||||||
metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root,
|
metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root,
|
||||||
@@ -388,15 +389,18 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
|
|||||||
func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) {
|
func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) {
|
||||||
shared := &document.Node{Kind: document.String, Text: "shared"}
|
shared := &document.Node{Kind: document.String, Text: "shared"}
|
||||||
extra := &document.Node{Kind: document.String, Text: "extra"}
|
extra := &document.Node{Kind: document.String, Text: "extra"}
|
||||||
expansion := &inheritanceScope{
|
inspection := &Inspection{
|
||||||
root: shared,
|
inheritance: []inheritanceOperation{{parent: shared, effective: shared}},
|
||||||
operations: []inheritanceOperation{{parent: shared, effective: shared}},
|
metadata: Metadata{inheritance: []inheritanceOperation{{parent: shared, effective: extra}}},
|
||||||
replacements: []inheritanceReplacement{{before: shared, after: extra}},
|
|
||||||
}
|
}
|
||||||
if !historyNodesWithin(2, nil, nil, nil, nil, nil, expansion) ||
|
counter := newRetainedNodeCounter(2)
|
||||||
historyNodesWithin(1, nil, nil, nil, nil, nil, expansion) {
|
if !addInspectionHistory(counter, inspection) || counter.count != 2 {
|
||||||
t.Fatal("shared evidence node accounting is incorrect")
|
t.Fatal("shared evidence node accounting is incorrect")
|
||||||
}
|
}
|
||||||
|
counter = newRetainedNodeCounter(1)
|
||||||
|
if addInspectionHistory(counter, inspection) {
|
||||||
|
t.Fatal("distinct inheritance evidence did not consume the budget")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) {
|
func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) {
|
||||||
|
|||||||
@@ -24,6 +24,83 @@ func parseIdentifiedInheritanceSequence(t *testing.T, sourceID, input string) *d
|
|||||||
return sequence
|
return sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreserveInheritanceIdentityIndexesCompoundFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mergedEntries := []document.Entry{
|
||||||
|
{Key: "before", Value: &document.Node{Kind: document.String, Text: "keep"}},
|
||||||
|
{Key: "id", KeyLocation: document.Location{SourceID: "parent", Line: 2}, ValueLocation: document.Location{SourceID: "parent", Line: 2}, Value: &document.Node{Kind: document.String, Text: "parent-id"}},
|
||||||
|
}
|
||||||
|
for index := 0; index < 64; index++ {
|
||||||
|
key := "setting-" + string(rune('a'+index))
|
||||||
|
mergedEntries = append(mergedEntries, document.Entry{Key: key, Value: &document.Node{Kind: document.String, Text: key}})
|
||||||
|
}
|
||||||
|
mergedEntries = append(mergedEntries,
|
||||||
|
document.Entry{Key: "a/b~c", KeyLocation: document.Location{SourceID: "parent", Line: 70}, ValueLocation: document.Location{SourceID: "parent", Line: 70}, Value: &document.Node{Kind: document.String, Text: "parent-literal"}},
|
||||||
|
document.Entry{Key: "missing", Value: &document.Node{Kind: document.String, Text: "retained"}},
|
||||||
|
document.Entry{Key: "after", Value: &document.Node{Kind: document.String, Text: "keep"}},
|
||||||
|
)
|
||||||
|
merged := &document.Node{Kind: document.Mapping, Mapping: mergedEntries}
|
||||||
|
child := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||||
|
{Key: "a/b~c", KeyLocation: document.Location{SourceID: "child", Line: 9, Column: 3}, ValueLocation: document.Location{SourceID: "child", Line: 9, Column: 10}, Value: &document.Node{Kind: document.String, Text: "child-literal", Location: document.Location{SourceID: "child", Line: 9, Column: 10}}},
|
||||||
|
{Key: "id", KeyLocation: document.Location{SourceID: "child", Line: 3, Column: 3}, ValueLocation: document.Location{SourceID: "child", Line: 3, Column: 7}, Value: &document.Node{Kind: document.String, Text: "child-id", Location: document.Location{SourceID: "child", Line: 3, Column: 7}}},
|
||||||
|
}}
|
||||||
|
beforeKeys := make([]string, len(merged.Mapping))
|
||||||
|
for index, entry := range merged.Mapping {
|
||||||
|
beforeKeys[index] = entry.Key
|
||||||
|
}
|
||||||
|
untouched := merged.Mapping[2].Value
|
||||||
|
|
||||||
|
preserveInheritanceIdentityFields(merged, child, []string{"id", "a/b~c", "missing"})
|
||||||
|
|
||||||
|
for index, entry := range merged.Mapping {
|
||||||
|
if entry.Key != beforeKeys[index] {
|
||||||
|
t.Fatalf("mapping order changed at %d: %q, want %q", index, entry.Key, beforeKeys[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []struct {
|
||||||
|
key string
|
||||||
|
text string
|
||||||
|
keyLine int
|
||||||
|
keyColumn int
|
||||||
|
valueLine int
|
||||||
|
valueColumn int
|
||||||
|
}{
|
||||||
|
{key: "id", text: "child-id", keyLine: 3, keyColumn: 3, valueLine: 3, valueColumn: 7},
|
||||||
|
{key: "a/b~c", text: "child-literal", keyLine: 9, keyColumn: 3, valueLine: 9, valueColumn: 10},
|
||||||
|
} {
|
||||||
|
var got document.Entry
|
||||||
|
for _, entry := range merged.Mapping {
|
||||||
|
if entry.Key == want.key {
|
||||||
|
got = entry
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got.Value == nil || got.Value.Text != want.text || got.KeyLocation.SourceID != "child" || got.KeyLocation.Line != want.keyLine || got.KeyLocation.Column != want.keyColumn ||
|
||||||
|
got.ValueLocation.SourceID != "child" || got.ValueLocation.Line != want.valueLine || got.ValueLocation.Column != want.valueColumn ||
|
||||||
|
got.Value.Location.SourceID != "child" || got.Value.Location.Line != want.valueLine || got.Value.Location.Column != want.valueColumn {
|
||||||
|
t.Fatalf("restored identity %q = %#v", want.key, got)
|
||||||
|
}
|
||||||
|
if got.Value == child.Mapping[0].Value || (want.key == "id" && got.Value == child.Mapping[1].Value) {
|
||||||
|
t.Fatalf("restored identity %q borrows its child value", want.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if merged.Mapping[2].Value != untouched {
|
||||||
|
t.Fatal("nonidentity sibling was replaced")
|
||||||
|
}
|
||||||
|
for _, entry := range merged.Mapping {
|
||||||
|
if entry.Key == "missing" && entry.Value.Text != "retained" {
|
||||||
|
t.Fatalf("identity absent from child was changed: %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.Mapping[0].Value.Text = "mutated"
|
||||||
|
for _, entry := range merged.Mapping {
|
||||||
|
if entry.Key == "a/b~c" && entry.Value.Text != "child-literal" {
|
||||||
|
t.Fatal("restored identity value aliases the child template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) {
|
func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) {
|
||||||
sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2
|
sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2
|
||||||
tenant: acme
|
tenant: acme
|
||||||
@@ -198,11 +275,6 @@ func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *test
|
|||||||
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
|
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
|
||||||
t.Fatal("nested inherited value did not retain its destination origin")
|
t.Fatal("nested inherited value did not retain its destination origin")
|
||||||
}
|
}
|
||||||
for _, replacement := range expanded.replacements {
|
|
||||||
if replacement.path.String() == "/jobs/0/id" {
|
|
||||||
t.Fatal("parent identity was treated as payload replaced by the child's identity")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) {
|
func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) {
|
||||||
|
|||||||
@@ -44,11 +44,8 @@ func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *tes
|
|||||||
if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 {
|
if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 {
|
||||||
t.Fatalf("direct replacement origin = %#v", count)
|
t.Fatalf("direct replacement origin = %#v", count)
|
||||||
}
|
}
|
||||||
if len(result.replacements) != 1 || result.replacements[0].path.String() != "/child/count" ||
|
if len(result.replacements) != 1 || result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" {
|
||||||
result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" ||
|
t.Fatalf("replacement accounting values = %#v", result.replacements)
|
||||||
len(result.replacements[0].previous) != 1 ||
|
|
||||||
len(projectInheritanceSteps(result.replacements[0].previous[0].origin)) != 1 {
|
|
||||||
t.Fatalf("displaced inheritance = %#v", result.replacements)
|
|
||||||
}
|
}
|
||||||
settings := result.origins["/child/settings"]
|
settings := result.origins["/child/settings"]
|
||||||
if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" ||
|
if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" ||
|
||||||
|
|||||||
123
internal/engine/inheritance_policy_index.go
Normal file
123
internal/engine/inheritance_policy_index.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// inheritancePolicyIndex borrows immutable policy entries. Semantic path
|
||||||
|
// segments keep mapping keys distinct from sequence indexes. Record-owned
|
||||||
|
// scopes have their own indexes, just as they have their own policy tables.
|
||||||
|
type inheritancePolicyIndex struct {
|
||||||
|
entry *collectionPolicyEntry
|
||||||
|
children map[pathSegment]*inheritancePolicyIndex
|
||||||
|
records map[int]*inheritancePolicyIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInheritancePolicyIndex(scope collectionPolicyScope) *inheritancePolicyIndex {
|
||||||
|
index := &inheritancePolicyIndex{}
|
||||||
|
for _, entry := range scope.collections {
|
||||||
|
index.set(entry)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *inheritancePolicyIndex) set(entry collectionPolicyEntry) {
|
||||||
|
node := index
|
||||||
|
for _, segment := range entry.destination.segments {
|
||||||
|
if node.children == nil {
|
||||||
|
node.children = make(map[pathSegment]*inheritancePolicyIndex)
|
||||||
|
}
|
||||||
|
if node.children[segment] == nil {
|
||||||
|
node.children[segment] = &inheritancePolicyIndex{}
|
||||||
|
}
|
||||||
|
node = node.children[segment]
|
||||||
|
}
|
||||||
|
node.entry = &entry
|
||||||
|
node.records = nil
|
||||||
|
for position, owner := range entry.policy.recordOrder {
|
||||||
|
scope := entry.policy.recordPolicies[owner]
|
||||||
|
if len(scope.collections) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node.records == nil {
|
||||||
|
node.records = make(map[int]*inheritancePolicyIndex)
|
||||||
|
}
|
||||||
|
node.records[position] = newInheritancePolicyIndex(scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localize copies only policies in the requested scope. Earlier record-owned
|
||||||
|
// policies are considered before direct entries added by destination expansion.
|
||||||
|
func (index *inheritancePolicyIndex) localize(prefix Path) collectionPolicyScope {
|
||||||
|
localized := collectionPolicyScope{}
|
||||||
|
node := index
|
||||||
|
for offset, segment := range prefix.segments {
|
||||||
|
if node == nil {
|
||||||
|
return localized
|
||||||
|
}
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
if record := node.records[segment.index]; record != nil {
|
||||||
|
remainder := Path{segments: prefix.segments[offset+1:]}
|
||||||
|
for _, entry := range record.localize(remainder).collections {
|
||||||
|
localized.set(entry.destination, entry.policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.children[segment]
|
||||||
|
}
|
||||||
|
var collect func(*inheritancePolicyIndex)
|
||||||
|
collect = func(node *inheritancePolicyIndex) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if node.entry != nil {
|
||||||
|
relative, _ := relativeHistoryPath(prefix, node.entry.destination)
|
||||||
|
localized.set(relative, cloneCollectionPolicyForInheritance(node.entry.policy))
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
collect(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(node)
|
||||||
|
return localized
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *inheritancePolicyIndex) absoluteScope(prefix Path) collectionPolicyScope {
|
||||||
|
scope := collectionPolicyScope{}
|
||||||
|
for _, entry := range index.localize(prefix).collections {
|
||||||
|
scope.set(appendDestination(prefix, entry.destination), entry.policy)
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace updates the walker's owned table and its borrowed index together.
|
||||||
|
// Retirement visits only direct entries below the changed destination.
|
||||||
|
func (index *inheritancePolicyIndex) replace(scope *collectionPolicyScope, prefix Path, replacement collectionPolicyScope) {
|
||||||
|
node := index
|
||||||
|
var parent *inheritancePolicyIndex
|
||||||
|
var last pathSegment
|
||||||
|
for _, segment := range prefix.segments {
|
||||||
|
if node == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent, last, node = node, segment, node.children[segment]
|
||||||
|
}
|
||||||
|
var remove func(*inheritancePolicyIndex)
|
||||||
|
remove = func(node *inheritancePolicyIndex) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if node.entry != nil {
|
||||||
|
delete(scope.collections, node.entry.destination.String())
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
remove(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove(node)
|
||||||
|
if parent == nil {
|
||||||
|
*index = inheritancePolicyIndex{}
|
||||||
|
} else {
|
||||||
|
delete(parent.children, last)
|
||||||
|
}
|
||||||
|
for _, entry := range replacement.collections {
|
||||||
|
scope.set(entry.destination, entry.policy)
|
||||||
|
index.set(scope.collections[entry.destination.String()])
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/engine/inheritance_policy_index_test.go
Normal file
111
internal/engine/inheritance_policy_index_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInheritancePolicyIndexKeepsSemanticScopesAndOwnedTemplates(t *testing.T) {
|
||||||
|
policy := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
nested := collectionPolicyScope{}
|
||||||
|
nested.set(Keys("group", "base", "items"), policy)
|
||||||
|
outer := policy
|
||||||
|
record := newCollectionRecordPolicy()
|
||||||
|
record.scope = nested
|
||||||
|
outer.setRecordPolicyAt(0, record)
|
||||||
|
scope := collectionPolicyScope{}
|
||||||
|
scope.set(Keys("records"), outer)
|
||||||
|
scope.set(Keys("numeric", "0", "items"), policy)
|
||||||
|
index := newInheritancePolicyIndex(scope)
|
||||||
|
|
||||||
|
first := index.localize(Keys("records").Index(0).Key("group"))
|
||||||
|
second := index.localize(Keys("records").Index(0).Key("group"))
|
||||||
|
if len(first.collections) != 1 || len(second.collections) != 1 {
|
||||||
|
t.Fatal("record-owned scope was not localized")
|
||||||
|
}
|
||||||
|
entry := first.collections["/base/items"]
|
||||||
|
entry.policy.identityFields[0] = "changed"
|
||||||
|
if second.collections["/base/items"].policy.identityFields[0] != "id" || nested.collections["/group/base/items"].policy.identityFields[0] != "id" {
|
||||||
|
t.Fatal("localized policy shares mutable template state")
|
||||||
|
}
|
||||||
|
if len(index.localize(Keys("records", "0")).collections) != 0 || len(index.localize(Keys("numeric").Index(0)).collections) != 0 {
|
||||||
|
t.Fatal("mapping keys and sequence indexes were conflated")
|
||||||
|
}
|
||||||
|
if len(index.localize(Keys("numeric", "0")).collections) != 1 {
|
||||||
|
t.Fatal("numeric mapping key did not retain its policy")
|
||||||
|
}
|
||||||
|
left := index.localize(Keys("records"))
|
||||||
|
right := index.localize(Keys("records"))
|
||||||
|
leftOwner := left.collections[""].policy.recordOrder[0]
|
||||||
|
rightOwner := right.collections[""].policy.recordOrder[0]
|
||||||
|
if leftOwner == rightOwner || leftOwner == record.owner || rightOwner == record.owner {
|
||||||
|
t.Fatal("independent templates share a record owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritancePolicyIndexReplacesOnlyTheChangedScope(t *testing.T) {
|
||||||
|
scope := collectionPolicyScope{}
|
||||||
|
policy := collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true}
|
||||||
|
for index := range 128 {
|
||||||
|
scope.set(Keys(fmt.Sprintf("group-%03d", index), "pending"), policy)
|
||||||
|
}
|
||||||
|
index := newInheritancePolicyIndex(scope)
|
||||||
|
changed := Keys("group-064")
|
||||||
|
if len(index.absoluteScope(changed).collections) != 1 {
|
||||||
|
t.Fatal("scope includes unrelated groups")
|
||||||
|
}
|
||||||
|
replacement := collectionPolicyScope{}
|
||||||
|
replacement.set(changed.Key("introduced"), policy)
|
||||||
|
index.replace(&scope, changed, replacement)
|
||||||
|
if len(scope.collections) != 128 {
|
||||||
|
t.Fatal("replacement lost unrelated pending declarations")
|
||||||
|
}
|
||||||
|
local := index.localize(changed)
|
||||||
|
if _, found := local.collections["/introduced"]; !found || len(local.collections) != 1 {
|
||||||
|
t.Fatal("retired policy survived or introduced policy was not indexed")
|
||||||
|
}
|
||||||
|
index.replace(&scope, changed, collectionPolicyScope{})
|
||||||
|
if len(index.localize(changed).collections) != 0 || len(scope.collections) != 127 {
|
||||||
|
t.Fatal("descendant retirement did not update the table and index together")
|
||||||
|
}
|
||||||
|
if len(index.localize(Keys("group-065")).collections) != 1 {
|
||||||
|
t.Fatal("retirement changed another scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceWideIndependentYamlCollectionScopes(t *testing.T) {
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString("configkit:\n sequences:\n")
|
||||||
|
for index := range 64 {
|
||||||
|
fmt.Fprintf(&body, " - {destination: [groups, group-%03d, base, items], key: id, mode: append}\n", index)
|
||||||
|
}
|
||||||
|
body.WriteString("groups:\n")
|
||||||
|
for index := range 64 {
|
||||||
|
fmt.Fprintf(&body, " group-%03d:\n base: {items: [{id: shared, value: parent}]}\n child: {extends: base, items: [{id: shared, value: child}]}\n", index)
|
||||||
|
}
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type definition struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Groups map[string]map[string]definition `yaml:"groups"`
|
||||||
|
}
|
||||||
|
loaded, err := testLoad[config](Bytes("wide-inheritance", []byte(body.String())))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := loaded.Metadata()
|
||||||
|
for name, group := range loaded.Config.Groups {
|
||||||
|
if len(group["base"].Items) != 1 || group["base"].Items[0].Value != "parent" || len(group["child"].Items) != 1 || group["child"].Items[0].Value != "child" {
|
||||||
|
t.Fatalf("independent scope %s composed incorrectly: %#v", name, group)
|
||||||
|
}
|
||||||
|
path := Keys("groups", name, "child", "items").Index(0).Key("value")
|
||||||
|
if info, exists := metadata.Lookup(path); !exists || !info.Explicit || info.Origin.Layer != LayerBase {
|
||||||
|
t.Fatalf("inherited destination lost source metadata at %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user