Compare commits
47 Commits
v0.9.0
...
d898cb193d
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
@@ -127,16 +116,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 +129,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -182,6 +183,15 @@ Current behavior belongs to the configuration contract's
|
|||||||
The completed remediation record, including the algorithm-audit findings, is
|
The completed remediation record, including the algorithm-audit findings, is
|
||||||
preserved at `ea46001:docs/roadmap/remediation.md`.
|
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 [consolidation roadmap](roadmap/consolidation.md) owns the current refactoring
|
||||||
|
proposal and the associated complexity-audit findings.
|
||||||
|
|
||||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
Potential enhancements are tracked in [future features](roadmap/future.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.
|
||||||
@@ -230,16 +240,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), [deferred clone review](roadmap/future.md#4-clone-traversal-review) | `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,10 @@ 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 accounting, and per-tier accounting; `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 +23,14 @@ 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.
|
||||||
|
|
||||||
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
|
||||||
@@ -78,10 +85,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,10 +7,13 @@ 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
|
||||||
@@ -31,9 +34,9 @@ Implemented component owners:
|
|||||||
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 +56,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 +83,62 @@ 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. The
|
||||||
|
ordinary and grouped entry points share a normalized `loadRequest`: 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 and load-wide and per-tier accounting 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). 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: 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. Typed clone extraction remains deferred in
|
||||||
|
[`future.md`](../roadmap/future.md#4-clone-traversal-review).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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)
|
||||||
@@ -1,222 +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
|
|
||||||
|
|
||||||
The completed remediation record at `ea46001:docs/roadmap/remediation.md`
|
|
||||||
documents 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.
|
|
||||||
481
docs/roadmap/consolidation.md
Normal file
481
docs/roadmap/consolidation.md
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
# ConfigKit Consolidation Toward 1.0
|
||||||
|
|
||||||
|
Status: selected target state; implementation pending. This document defines the
|
||||||
|
refactor's scope and rationale; it does not authorize a release. The scope
|
||||||
|
questions are settled. The [implementation plan](implementation.md) owns the
|
||||||
|
concrete steps, sequencing, and implementation progress.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Consolidate the machinery introduced by recent import, collection, inheritance,
|
||||||
|
and discovery work so ConfigKit is easier to understand, maintain, and adopt
|
||||||
|
before its public contracts become stable. Success means fewer independently
|
||||||
|
maintained paths through the same behavior, clearer state ownership, and concise
|
||||||
|
consumer workflows. Reducing a function's line count is useful only when it
|
||||||
|
serves those outcomes.
|
||||||
|
|
||||||
|
The review considered public API consolidation as well as internal extraction.
|
||||||
|
The settled public direction retains the existing simple and layered loading APIs,
|
||||||
|
consolidates their private translation and acquisition machinery, and removes
|
||||||
|
unused reverse inspection conversions. No public signature or configuration
|
||||||
|
semantic change is planned in this effort. This is an assessment of the reviewed
|
||||||
|
consumer workflows, not a requirement to preserve every beta API in perpetuity.
|
||||||
|
|
||||||
|
## Evidence And Baseline
|
||||||
|
|
||||||
|
Reviewed worktree: `c9574ef`, immediately after the `v0.10.0` release at `5959537`.
|
||||||
|
This document replaces the earlier `complexity.md` roadmap and `remediation.md`
|
||||||
|
audit record. Their recommendations and relevant evidence are incorporated here;
|
||||||
|
the original documents remain available in Git history:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show fbaaaf1:docs/roadmap/complexity.md
|
||||||
|
git show fbaaaf1:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The earlier roadmap reviewed `7a83704f43ef9870db33661231cdff5d6ee502f7`, before
|
||||||
|
discovery. The audit reviewed `5959537d1adc2516e7f7e8881df72b707ec48c3f`, including
|
||||||
|
implemented discovery. The consolidation review checked their recommendations
|
||||||
|
against that newer baseline and also considered the public API.
|
||||||
|
|
||||||
|
This review inspected the current public load/input/discovery declarations and
|
||||||
|
maintained consumer examples, the shared request executor and acquisition flow,
|
||||||
|
import parsing and selection, inheritance execution, reflective projection and
|
||||||
|
cloning, schema field construction, and filesystem-context derivation. Graph
|
||||||
|
caller traces and text-reference checks confirmed the unused reverse conversion
|
||||||
|
chain. This was source review, not a new runtime correctness or performance audit.
|
||||||
|
Neither this review nor the audit established a runtime correctness defect.
|
||||||
|
|
||||||
|
### Audit Measurements
|
||||||
|
|
||||||
|
The audit refreshed the codebase-memory graph and used function size, cognitive
|
||||||
|
complexity, branch counts, and parameter counts to rank candidates for source
|
||||||
|
inspection. It indexed 901 non-test functions/methods and 928 test-file
|
||||||
|
declarations. Its fast index excludes examples, documentation, and scripts; a
|
||||||
|
tracked-file inventory covered those separately. Standalone `gocyclo`, `gocognit`,
|
||||||
|
`scc`, and `cloc` tools were unavailable.
|
||||||
|
|
||||||
|
These are the graph tool's reported measurements at the audited revision.
|
||||||
|
Function spans include comments and blank lines. The scores support relative
|
||||||
|
triage; they are not standardized acceptance thresholds or evidence of a defect.
|
||||||
|
|
||||||
|
| Function | Source | Lines | Cognitive score |
|
||||||
|
| --- | --- | ---: | ---: |
|
||||||
|
| `valueOperation.project` | [projection.go](../../internal/engine/projection.go) | 195 | 174 |
|
||||||
|
| `parseImportDeclarationSpec` | [import_spec.go](../../internal/engine/import_spec.go) | 127 | 129 |
|
||||||
|
| `valueOperation.clone` | [clone.go](../../internal/engine/clone.go) | 130 | 104 |
|
||||||
|
| `schemaBuilder.describeFields` | [schema.go](../../internal/engine/schema.go) | 99 | 100 |
|
||||||
|
| `assembleBaseInputTrackedForRole` | [load_input.go](../../internal/engine/load_input.go) | 188 | 96 |
|
||||||
|
| `selectDirectoryImportCandidates` | [import_selection.go](../../internal/engine/import_selection.go) | 154 | 96 |
|
||||||
|
| `valueBuilder.build` | [value.go](../../internal/engine/value.go) | 104 | 93 |
|
||||||
|
| `decodeValue` | [decode.go](../../internal/engine/decode.go) | 115 | 85 |
|
||||||
|
| `applyInheritanceScopeTemplates` | [inheritance_schedule.go](../../internal/engine/inheritance_schedule.go) | 219 | 75 |
|
||||||
|
| `contextsForAcquiredSources` | [source_context.go](../../internal/engine/source_context.go) | 102 | 72 |
|
||||||
|
|
||||||
|
At that revision, the engine contained 88 production Go files and approximately
|
||||||
|
20,144 physical lines; the root facade contained 16 files and 1,861 lines.
|
||||||
|
`imports.go` was the largest production file at 1,195 lines. These measurements
|
||||||
|
locate concentrated responsibilities. They do not establish a need for more
|
||||||
|
packages or make the smaller document/YAML visitors and explicit facade
|
||||||
|
conversions maintenance defects.
|
||||||
|
|
||||||
|
### Findings And Recommended Treatment
|
||||||
|
|
||||||
|
The finding numbers below preserve the audit's identifiers. The selected work
|
||||||
|
remains unimplemented; the cloning portion of finding 3 is deferred to future
|
||||||
|
work. Retiring the original documents does not mark their findings implemented.
|
||||||
|
|
||||||
|
| Audit finding | Maintenance priority | Assessment and recommended treatment |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1. Import entry points and responsibilities | Higher | Main refactor. Include shared acquisition normalization, import-default ownership, declaration parsing, and directory admission. |
|
||||||
|
| 2. Shared inheritance executor | Higher | Main refactor. Expose meaningful phases and explicit combination results while retaining one executor. |
|
||||||
|
| 3. Projection and cloning | Higher for projection; cloning deferred | Main refactor for projection. Clone traversal review is tracked separately in [future.md](future.md#4-clone-traversal-review). |
|
||||||
|
| 4. Schema field discovery and inline assembly | Medium | Include a focused refactor coordinated with projection. Keep one schema builder and its two descriptor views. |
|
||||||
|
| 5. Resolved filesystem context | Medium | Include alongside acquisition work. Reuse resolved facts without merging resource lifetimes or changing validation timing. |
|
||||||
|
| 6. Unused reverse inspection conversions | Small, independently reviewable cleanup | Include in the root facade and engine. Retain useful forward-conversion and detached-result coverage. |
|
||||||
|
|
||||||
|
Findings 1–3 confirm the earlier roadmap's main opportunities; findings 4–6 add
|
||||||
|
focused work. The following sections own their rationale and intended changes.
|
||||||
|
Implementation and validation will establish completion of the selected work;
|
||||||
|
the deferred clone review retains its separate disposition.
|
||||||
|
|
||||||
|
Completed correctness and algorithmic remediations remain part of the baseline,
|
||||||
|
including sequence records preserved at `ea46001:docs/roadmap/remediation.md`
|
||||||
|
and discovery records preserved at `0369111:docs/roadmap/remediation.md`.
|
||||||
|
Item-scope application matching and inheritance policy localization are examples
|
||||||
|
of completed corrections that overlap this work and whose regressions should
|
||||||
|
remain protected.
|
||||||
|
|
||||||
|
The [architecture policy](../policy/architecture.md),
|
||||||
|
[configuration contract](../config.md), and exported declaration comments own
|
||||||
|
existing constraints and behavior. The
|
||||||
|
[component overview](../internal/overview.md) and
|
||||||
|
[import internals](../internal/imports.md) locate implementation responsibilities.
|
||||||
|
|
||||||
|
## Consumer-Facing Target
|
||||||
|
|
||||||
|
### Keep Simple Loading Simple And Advanced Inputs Explicit
|
||||||
|
|
||||||
|
The current APIs already provide progressive setup:
|
||||||
|
|
||||||
|
- A simple consumer calls `Load[Config](File(path))`.
|
||||||
|
- A consumer with defaults, imports, or resolution supplies `Options[Config]`.
|
||||||
|
- Explicit discovery produces an ordinary `Source`, so it uses the same loading
|
||||||
|
call and lifecycle.
|
||||||
|
- A layered consumer supplies an `Input` and `LayeredOptions[Config]`, with
|
||||||
|
`Underlay.Inputs` distinguishing strict peers from precedence tiers.
|
||||||
|
|
||||||
|
The two loading functions already converge on `loadRequest` and
|
||||||
|
`executeLoadRequest`. Their public differences have meaning: ordinary options
|
||||||
|
include the one input's controls; layered loading has per-input controls and a
|
||||||
|
separate overlay filesystem. Ordinary overlay context follows its selected
|
||||||
|
source, whereas layered overlay context is independently specified. These are
|
||||||
|
observable conventions, not interchangeable forwarding spellings.
|
||||||
|
|
||||||
|
**Decision (OQ-1, settled):** retain `Load`, `LoadLayered`, `Options`, `LayeredOptions`,
|
||||||
|
`Input`, and `Underlay`. Remove repeated translation and option reconstruction
|
||||||
|
behind them. Keep common application rules in `Definition`; do not require
|
||||||
|
applications to build a runtime loader or manually coordinate phases.
|
||||||
|
|
||||||
|
A single public request type could place main input, underlays, and common
|
||||||
|
options together. It would reduce duplicated option fields and make promotion
|
||||||
|
from ordinary to layered loading more uniform. A convenience entry could preserve
|
||||||
|
the simplest call. Such a design would still need to resolve the existing
|
||||||
|
overlay-context differences and demonstrate a clearer consumer model. The
|
||||||
|
maintained examples do not show enough consumer benefit to justify that redesign
|
||||||
|
in this effort. Retaining the current APIs accepts some duplicated public option
|
||||||
|
fields and setup changes when adopting underlays; internal consolidation and
|
||||||
|
clearer guidance address the observed maintenance needs. The decision is based on
|
||||||
|
the resulting API, rather than the amount of refactoring needed to achieve it.
|
||||||
|
|
||||||
|
### Preserve Useful Convenience And Ownership Boundaries
|
||||||
|
|
||||||
|
| Surface reviewed | Recommended public end state |
|
||||||
|
| --- | --- |
|
||||||
|
| `Source`, `Filesystem`, `Search`, filename groups and presets | Retain typed, reusable source/search descriptions. Discovery selects a concrete source during loading; applications do not loop over complete loads. |
|
||||||
|
| `FilesystemContext` | Retain the public description and current host/native rules. Consolidate its resolved private representation; do not expose acquisition handles or engine context records. |
|
||||||
|
| `SequenceIdentityAt` and `SequenceIdentityMatching` | Retain the exact-path convenience and dynamic selector form over the common registration model. Both serve distinct consumer shapes. |
|
||||||
|
| `Definition`, resolution and validation callbacks | Retain application-owned domain rules inside one recorded lifecycle. Preserve the distinction between mutable recorded resolution and read-only validation. |
|
||||||
|
| Results, metadata, explanations, source context and diff | Retain detached, safe inspection with consumer-owned presentation. Remove unused inverse conversions without removing supported queries. |
|
||||||
|
| Root wrappers and private conversions | Retain root-owned public types and explicit ownership conversion under ADR 0006. Consolidate repeated real conversions and delete unused ones. |
|
||||||
|
|
||||||
|
The consumer-facing work is to make the ordinary path easier to find and show
|
||||||
|
how additional features compose, using the existing maintained examples. Update
|
||||||
|
the adoption guide with a concise choice between simple loading, discovery, and
|
||||||
|
layered loading, and show which options belong to an input versus a whole load.
|
||||||
|
Keep exact behavior in its canonical owners. Examples should make application
|
||||||
|
rules recognizable and avoid requiring consumers to recreate generic merging,
|
||||||
|
presence, or provenance machinery.
|
||||||
|
|
||||||
|
Use the current simple README call, bundled-file and layered catalog examples,
|
||||||
|
the import-focused prompt catalog, and the discovered sequence catalog to assess
|
||||||
|
the end state. The latter exercises imports, underlays, independent overlay
|
||||||
|
context, CLI assignment, and recorded resolution in one workflow. A high number
|
||||||
|
of options in that example does not by itself justify a new abstraction.
|
||||||
|
|
||||||
|
## Internal Target
|
||||||
|
|
||||||
|
Keep the root facade, cohesive `internal/engine`, ordered document model, and
|
||||||
|
private YAML adapter. Use ordinary private records, functions, and focused files
|
||||||
|
to express responsibilities. Existing mapping combination, sequence combination,
|
||||||
|
dependency scheduling, schema descriptors, and traversal accounting remain shared.
|
||||||
|
|
||||||
|
`executeLoadRequest` stays the visible lifecycle coordinator. It should show
|
||||||
|
acquisition, assembly, inheritance, decoding/CLI assignment, resolution, secret
|
||||||
|
processing, validation, and publication in their established order. Extract a
|
||||||
|
phase only when it gives that work a clear input, output, or ownership boundary.
|
||||||
|
Do not replace the visible lifecycle with a registry of configurable stages.
|
||||||
|
|
||||||
|
### 1. Normalize Loading Inputs And Consolidate Import Acquisition
|
||||||
|
|
||||||
|
The current executor constructs an engine `Options[T]` from `loadRequest`, and
|
||||||
|
underlay acquisition constructs `Options[struct{}]` to call the same input
|
||||||
|
assembler. `assembleBaseInputTrackedForRole` then combines discovery, directive
|
||||||
|
processing, version rules, imports, placement, accounting, and contexts. Import
|
||||||
|
entry points carry long positional lists through several forwarding variants.
|
||||||
|
This repeats the representation of information already normalized for a load.
|
||||||
|
|
||||||
|
In [imports.go](../../internal/engine/imports.go), the
|
||||||
|
`loadImportedDocumentsTrackedWithVersion`,
|
||||||
|
`loadImportedDocumentsTrackedWithProcessing`, and
|
||||||
|
`loadImportedDocumentsTrackedWithProcessingDeferred` variants have corresponding
|
||||||
|
`loadImportedWithAccess...` variants. Both final signatures carry 13 parameters.
|
||||||
|
Placement, processing results, sequence policy, accounting, repetition tracking,
|
||||||
|
namespace identity, and deferred assembly must stay aligned through the chain.
|
||||||
|
The same file's `importTraversal.visitPath` combines source-admission decisions
|
||||||
|
in 174 lines, alongside acquisition, pending-item assembly, and evidence work.
|
||||||
|
|
||||||
|
Introduce cohesive private acquisition inputs and explicit state ownership,
|
||||||
|
reusing `loadRequest`, `loadInput`, `acquisitionScope`, and `importTraversal`
|
||||||
|
where they fit. Translate ordinary and layered public declarations into the
|
||||||
|
shared loading model once. Acquisition should receive the source, input role,
|
||||||
|
placement, context, and policies it needs without a fabricated application type
|
||||||
|
or a reconstructed broad options object. Small adapters may preserve meaningful
|
||||||
|
entry operations while callers migrate; remove obsolete forwarding variants
|
||||||
|
within the completed refactor.
|
||||||
|
|
||||||
|
Separate immutable policy, mutable processing state, and results. Move import
|
||||||
|
defaults out of `sourceVersionState` and into the load/acquisition policy owner.
|
||||||
|
Version state continues to own version processing, projected effective version
|
||||||
|
location, evidence, and diagnostics. Local and effective version rules have
|
||||||
|
different meanings and must remain distinguishable.
|
||||||
|
|
||||||
|
The state boundaries are especially important:
|
||||||
|
|
||||||
|
| Lifetime or scope | State and responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| Whole load | Aggregate resource accounting, application definition/registrations, import defaults, shared version evidence and diagnostics. |
|
||||||
|
| One strict tier | Repetition tracking shared by that tier's inputs, namespace scheduling anchors, and its inclusion/composition graph relationship. A later tier may reuse a file. |
|
||||||
|
| Active import traversal | Active routes and inclusion edges, filesystem access ownership, and cycle detection, including edges crossing item-scope boundaries. |
|
||||||
|
| One composition scope | Peer contributions, pending collection declarations, item-local helpers, and deferred item placement. Nested scopes do not become separate precedence tiers. |
|
||||||
|
| Retained result | Source records, effective destinations, origins, history, and passive resource-context descriptions. |
|
||||||
|
|
||||||
|
Keep repetition tracking tier-scoped. In the current code,
|
||||||
|
`acquireUnderlayGroupScopes` creates a fresh visited map per group. Preserve valid
|
||||||
|
reuse across tiers while keeping shared aggregate limits. Namespace equality,
|
||||||
|
candidate spelling, and stable scheduling keys also serve different purposes.
|
||||||
|
|
||||||
|
Give acquisition, already-acquired scope assembly, and evidence projection
|
||||||
|
focused homes. Keep discovery at the acquisition boundary: resolve a search once
|
||||||
|
per input consumption, then process the concrete file or absence. Preserve
|
||||||
|
source IDs and source-local version processing in acquisition order, even when
|
||||||
|
composition uses a different dependency-ready order. The layered main is acquired
|
||||||
|
before underlays; lower-tier identity can still inform its later strict assembly.
|
||||||
|
|
||||||
|
Within `parseImportDeclarationSpec`, extract boolean/collection/placement field
|
||||||
|
handling and selector-value parsing where it clarifies responsibilities. Keep
|
||||||
|
field presence, authored error locations, selector applicability, and the order
|
||||||
|
of consequential validation visible. Within `selectDirectoryImportCandidates` and
|
||||||
|
`importTraversal.visitPath`, separate admission and descent decisions from
|
||||||
|
traversal orchestration where they represent distinct work. Retain the existing
|
||||||
|
root deduplication, candidate union, exclusions, sorting, and active-path cycle
|
||||||
|
rules. No shared abstraction should conflate literal first-match main discovery
|
||||||
|
with multi-file import selection.
|
||||||
|
|
||||||
|
### 2. Share Resolved Filesystem Facts Without Sharing Live Handles
|
||||||
|
|
||||||
|
`importFilesystem`, `nativeFilesystemAccess`, and `contextsForAcquiredSources`
|
||||||
|
derive overlapping root/base/namespace facts. The latter reconstructs context
|
||||||
|
from combinations of source kind and optional fields, including absent input.
|
||||||
|
Discovery and independent layered overlays make these combinations consequential.
|
||||||
|
Context construction is used by ordinary acquisition and both selected-overlay
|
||||||
|
paths, so those callers need to use the same applicable derivation rules.
|
||||||
|
|
||||||
|
Use a small engine-owned resolved-context description for the facts actually
|
||||||
|
needed by acquisition and passive views: namespace identity/kind, resolved root,
|
||||||
|
source base, and selected source path when present. Preserve the distinction
|
||||||
|
between host paths and native slash paths. Reuse already resolved facts where
|
||||||
|
available instead of repeatedly deriving them from the original options.
|
||||||
|
|
||||||
|
Resolution and validation can occur at different existing lifecycle points.
|
||||||
|
The record must not force every source to perform all filesystem checks early.
|
||||||
|
Context descriptions, library-owned acquisition handles, and returned passive
|
||||||
|
resource views have different lifetimes. Closing acquisition must remain
|
||||||
|
explicit on success and failure; retaining context must not retain an open root
|
||||||
|
handle or assume ownership of a borrowed `fs.FS`.
|
||||||
|
|
||||||
|
Cover selected and absent discovery outcomes, root-only absent host context,
|
||||||
|
native sources, in-memory inputs with explicit context, and independently rooted
|
||||||
|
overlays. Passive context remains associated with the source that contributed
|
||||||
|
the value, including retained underlay and inherited values. Use the acquired
|
||||||
|
source's canonical identity where available. Preserve containment failures,
|
||||||
|
source associations, deferred search bases, and terminal selected-file errors.
|
||||||
|
|
||||||
|
Acquisition and context handling use the same private handoff rather than
|
||||||
|
maintaining parallel models for overlapping callers. The description does not
|
||||||
|
replace the public `FilesystemContext` or `ResourceContext` contracts.
|
||||||
|
|
||||||
|
### 3. Make Inheritance Combination Results Explicit
|
||||||
|
|
||||||
|
Keep the mapping and identified-record adapters and one shared executor.
|
||||||
|
`applyInheritanceScopeTemplates` currently prepares templates, mutates values
|
||||||
|
through merge callbacks, projects policy and origins, records replacements and
|
||||||
|
replay evidence, and enforces budgets in one body. The callbacks mutate several
|
||||||
|
surrounding result collections, making a combination difficult to review alone.
|
||||||
|
|
||||||
|
Give the executor named operations for child/control preparation, parent payload
|
||||||
|
and policy preparation, combination, evidence construction, and retained-node
|
||||||
|
accounting. Use a small private result for outputs of one combination where it
|
||||||
|
replaces unrelated surrounding mutations. Value, policy, origin, replacement,
|
||||||
|
and sequence-position evidence should come from the same combination outcome;
|
||||||
|
do not rerun a merge to reconstruct its history.
|
||||||
|
|
||||||
|
Keep borrowed authored templates separate from owned resolved payloads and from
|
||||||
|
retained history snapshots. Parent identity exclusion and child identity/origin
|
||||||
|
preservation must apply consistently to values and history. Preserve nested
|
||||||
|
unresolved instructions for destination-local evaluation, policy retirement on
|
||||||
|
actual replacement, cancellation, and source-version movement through sequences.
|
||||||
|
|
||||||
|
The shared Kahn scheduler with stable keys remains unchanged. Resolve enclosing
|
||||||
|
relationships before nested relationships and retain assembled record order.
|
||||||
|
Preserve expansion limits and retained-history accounting for shared nodes;
|
||||||
|
extraction must not charge shared evidence twice or make an unbounded snapshot.
|
||||||
|
|
||||||
|
### 4. Separate Projection Handlers And Clarify Schema Assembly
|
||||||
|
|
||||||
|
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
|
||||||
|
the operation-state owner. Move substantial struct, map, and sequence projection
|
||||||
|
into focused handlers. Keep pointer/dynamic dispatch and scalar representation
|
||||||
|
choices visible. All handlers share the same cycle, depth, and node state.
|
||||||
|
|
||||||
|
Struct projection needs particular care: physical members drive traversal,
|
||||||
|
flattened fields drive name lookup and sensitivity, and an inline container is
|
||||||
|
not retained as an extra output node. Preserve declaration order, deterministic
|
||||||
|
map order, nil/empty distinctions, collision diagnostics, codec failures, and
|
||||||
|
secret classification. Scalar projection retains the existing number, duration,
|
||||||
|
text-codec, and secret-reference representations. Keep cycle entries active
|
||||||
|
through child traversal and balance their release on every exit.
|
||||||
|
|
||||||
|
In `schemaBuilder.describeFields`, separate field discovery and tag parsing from
|
||||||
|
inline struct assembly, inline map assembly, and ordinary field registration.
|
||||||
|
Retain one builder/cache and its recursion detection. Make the paired updates to
|
||||||
|
`members`, flattened `fields`, `fieldByName`, and `inlineMap` easy to follow.
|
||||||
|
Keep index-path construction and inherited secret flags intact. These descriptor
|
||||||
|
views are both needed; combining them into one collection would shift complexity
|
||||||
|
into decoding and projection.
|
||||||
|
Keep pointer-chain and recursive-inline decisions explicit, preserve supported
|
||||||
|
field/tag forms and ambiguity paths, and retain cache/building-state behavior.
|
||||||
|
|
||||||
|
Clone traversal review and cleanup are deferred to
|
||||||
|
[future.md](future.md#4-clone-traversal-review). This effort preserves cloning's
|
||||||
|
existing behavior while changing the shared schema and projection machinery.
|
||||||
|
Keep projection's document-building semantics distinct from typed copying.
|
||||||
|
The mostly explicit dispatch in `decodeValue` and `valueBuilder.build` does not
|
||||||
|
warrant a generic reflection visitor solely because it has a high complexity
|
||||||
|
score.
|
||||||
|
|
||||||
|
### 5. Remove Unused Inspection Conversions And Consolidate Real Adapters
|
||||||
|
|
||||||
|
Remove `toEngineSourceInfo`, `toEngineOrigin`, `toEngineSourceVersionCheck`, and
|
||||||
|
`toEngineImportSelections` from [conversion.go](../../conversion.go), along with
|
||||||
|
the engine adapters used only by that chain.
|
||||||
|
At the reviewed revision, the three callers of `toEngineSourceInfo` are tests;
|
||||||
|
`toEngineOrigin` has none. `SourceInfoWithPlacement`,
|
||||||
|
`SourceInfoWithImportSelections`, `importSelectorFromString`, and
|
||||||
|
`importSelectionOutcomeFromString` in [records.go](../../internal/engine/records.go)
|
||||||
|
have no production role beyond that reverse chain. Consumers receive inspection
|
||||||
|
records; production facade operations do not send those records back to the
|
||||||
|
engine. The unused inverse path adds another ownership mapping to maintain as
|
||||||
|
inspection evidence grows. Recheck callers at the implementation baseline before
|
||||||
|
removal.
|
||||||
|
|
||||||
|
Rewrite the affected [record tests](../../records_test.go) around real
|
||||||
|
engine-to-public conversion and detached public inspection. Preserve useful
|
||||||
|
placement, version evidence, import-selection, and copy-isolation assertions.
|
||||||
|
Avoid manufacturing replacement production setters solely to keep an old
|
||||||
|
round-trip fixture working. Existing facade loads already provide genuine sources
|
||||||
|
and inspection records.
|
||||||
|
|
||||||
|
Retain reverse conversions that serve real inputs: `toEngineDiagnostics`, for
|
||||||
|
example, supports `WriteDiagnostics`. Consolidate repeated override/input
|
||||||
|
conversion in `toEngineOptions` and `toEngineLayeredOptions` where semantics
|
||||||
|
match. Keep explicit root-to-engine ownership conversion and the public-boundary
|
||||||
|
checks. A reflective transport framework or public engine aliases would obscure
|
||||||
|
the boundary rather than simplify it.
|
||||||
|
|
||||||
|
## Scope, Compatibility, And Policy Consequences
|
||||||
|
|
||||||
|
The proposed work spans private engine code, private root-facade conversions,
|
||||||
|
affected tests, consumer guidance, and implementation documentation. Under the
|
||||||
|
settled answer to [OQ-1](#oq-1-should-the-public-loading-apis-change-in-this-effort),
|
||||||
|
preserve public types/signatures and the behavior owned by the current contracts,
|
||||||
|
including:
|
||||||
|
|
||||||
|
- Acquisition versus composition ordering, strict scopes versus precedence
|
||||||
|
tiers, identity ownership, placement, and source-version protection.
|
||||||
|
- Discovery exhaustion and error behavior, source lifetime, passive resource
|
||||||
|
contexts, and host/native namespace distinctions.
|
||||||
|
- Defaults, post-decode CLI assignment, recorded resolution, secret handling,
|
||||||
|
final registered-identity enforcement, and semantic validation.
|
||||||
|
- Explicit presence, provenance/history, sensitivity, safe diagnostics and diff,
|
||||||
|
frozen inspection, and detached publication/query results.
|
||||||
|
- Contracted diagnostic codes, paths, coordinates, ordering, and resource limits.
|
||||||
|
|
||||||
|
Do not turn incidental helper order, exact private representations, numerical
|
||||||
|
complexity scores, or allocation counts into new guarantees. Keep the existing
|
||||||
|
algorithmic remediations effective; watch for repeated whole-tree traversals,
|
||||||
|
policy scans, or copies introduced by helper extraction.
|
||||||
|
|
||||||
|
The recommendation fits the existing architecture policy and ADRs 0006,
|
||||||
|
0015, 0017, 0018, and 0019. It needs no new architectural ownership decision or
|
||||||
|
policy revision merely to introduce private helpers. Update the component
|
||||||
|
overview and import internals when code moves. Accepted ADR decision content
|
||||||
|
remains unchanged. A public loading-model redesign is outside this effort's
|
||||||
|
settled scope.
|
||||||
|
|
||||||
|
Pre-1.0 public changes are possible under the [release policy](../policy/release.md),
|
||||||
|
but they must have a consumer benefit and documented impact. This draft does not
|
||||||
|
propose a parallel compatibility API or temporary migration scaffold. It also
|
||||||
|
does not make refactoring completion a new release gate or establish that 1.0
|
||||||
|
is otherwise ready. Future performance experiments remain in
|
||||||
|
[future.md](future.md#3-performance-experiments); no benchmark campaign was run
|
||||||
|
or is required to claim readability improvements.
|
||||||
|
|
||||||
|
## Validation And Completion
|
||||||
|
|
||||||
|
Use the [development guide](../development.md) and
|
||||||
|
[testing policy](../policy/testing.md) for focused checks and the ordinary full
|
||||||
|
development check. Preserve existing behavior tests; rewrite tests coupled only
|
||||||
|
to removed plumbing. Add a test when a consequential handoff risk lacks coverage,
|
||||||
|
not for every new helper.
|
||||||
|
|
||||||
|
| Work area | Existing protection and review focus |
|
||||||
|
| --- | --- |
|
||||||
|
| Acquisition and imports | Selection, defaults, scheduling, relative/item placement, source-version evidence, aggregate limits, repetition within versus across tiers, and ordinary/layered equivalence. |
|
||||||
|
| Resolved contexts | Host/native access, discovery selected/absent outcomes, terminal context failures, source-relative resources, independent overlay context, and handle closure. |
|
||||||
|
| Inheritance | Mapping and record inheritance, nested destination-local evaluation, child identity, policy lifetime, version movement, history ordering, sensitivity, and retained-node budgets. |
|
||||||
|
| Projection/schema | Inline fields, recursive schemas, dynamic values, nil/empty values, cycles and limits, text codecs, secret states, callback ownership, and independent loads; preserve existing cloning behavior where shared descriptors/state are affected. |
|
||||||
|
| Facade and consumers | Forward conversion, detached records, supported API boundaries, source-form equivalence, maintained simple/catalog/discovery examples, and safe explanation/diff. |
|
||||||
|
|
||||||
|
Examples of established boundaries include
|
||||||
|
[source-form equivalence](../../facade_equivalence_test.go),
|
||||||
|
[discovery integration](../../facade_discovery_test.go),
|
||||||
|
[final identity validation](../../facade_final_sequence_identity_test.go),
|
||||||
|
[inheritance history](../../internal/engine/inheritance_history_test.go), and
|
||||||
|
[projection and cloning](../../internal/engine/projection_test.go).
|
||||||
|
Use applicable race checks when changing ownership or shared state. Reuse checks
|
||||||
|
for unchanged content and keep benchmark work separate unless new evidence
|
||||||
|
justifies it and it is authorized.
|
||||||
|
|
||||||
|
Completion requires reviewable evidence that:
|
||||||
|
|
||||||
|
- Simple and advanced consumer examples remain concise and valid, with no new
|
||||||
|
public concepts required for existing workflows.
|
||||||
|
- Both load entry points reach one normalized internal model; acquisition no
|
||||||
|
longer reconstructs broad options or uses version state to transport defaults.
|
||||||
|
- Import, context, inheritance, and reflection responsibilities have identifiable
|
||||||
|
owners and explicit handoffs without duplicate semantic implementations.
|
||||||
|
- Unused reverse conversions and their exclusive adapters are removed, with
|
||||||
|
useful inspection ownership coverage retained.
|
||||||
|
- Contracted values, presence, provenance/history, diagnostics, sensitivity,
|
||||||
|
resource lifetime, and algorithmic corrections remain protected by appropriate
|
||||||
|
tests and source review.
|
||||||
|
- The reviewed scope is accounted for against all six audit findings, with clone
|
||||||
|
traversal review deferred to future work. Completed portions have validation
|
||||||
|
evidence rather than being closed because code moved.
|
||||||
|
- Consumer guidance, contributor references, and internal documentation describe
|
||||||
|
the resulting implementation. Current contracts continue to own behavior.
|
||||||
|
|
||||||
|
The [implementation plan](implementation.md) defines private records, file owners,
|
||||||
|
and stage boundaries. No open scope questions remain, and no additional feature
|
||||||
|
semantics are needed to carry out the selected refactors.
|
||||||
|
|
||||||
|
## Settled Decisions
|
||||||
|
|
||||||
|
### OQ-1: Should The Public Loading APIs Change In This Effort?
|
||||||
|
|
||||||
|
**Settled:** retain `Load`/`Options`, `LoadLayered`/`LayeredOptions`, `Input`, and
|
||||||
|
`Underlay`, including their existing overlay-context behavior. Consolidate their
|
||||||
|
private inputs, conversions, and acquisition machinery, and improve consumer
|
||||||
|
guidance. The [consumer-facing target](#consumer-facing-target) records the
|
||||||
|
rationale and tradeoffs. This decision governs the consolidation effort; it does
|
||||||
|
not establish a perpetual restriction on future API design.
|
||||||
|
|
||||||
|
### OQ-2: How Much Clone Cleanup Should This Effort Commit To?
|
||||||
|
|
||||||
|
**Settled:** defer clone traversal review and any resulting cleanup to
|
||||||
|
[future.md](future.md#4-clone-traversal-review). Neither the review nor clone
|
||||||
|
refactoring is part of this consolidation effort. Projection and schema
|
||||||
|
refactoring remain in scope, including protection of existing cloning behavior
|
||||||
|
where they share descriptors or operation state. The future review can conclude
|
||||||
|
that no clone edits are warranted; its goal is demonstrated maintenance benefit.
|
||||||
@@ -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.
|
|
||||||
@@ -83,3 +83,36 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
|
|||||||
across loads without introducing aliasing?
|
across loads without introducing aliasing?
|
||||||
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
||||||
without excessive retained memory?
|
without excessive retained memory?
|
||||||
|
|
||||||
|
## 4. Clone traversal review
|
||||||
|
|
||||||
|
Status: deferred. Review typed configuration cloning after the projection and
|
||||||
|
schema consolidation work, when the resulting structure can inform the review.
|
||||||
|
This is future maintenance work, not a prerequisite for completing consolidation.
|
||||||
|
|
||||||
|
The complexity audit identified `valueOperation.clone` in
|
||||||
|
[clone.go](../../internal/engine/clone.go) as a candidate for cleanup. Much of its
|
||||||
|
size is straightforward type dispatch. Focused handlers may clarify allocation,
|
||||||
|
recursive copying, cycle tracking, secret-state handling, or text-codec
|
||||||
|
reconstruction, but extraction may also add indirection without making the code
|
||||||
|
easier to understand.
|
||||||
|
|
||||||
|
When this work is taken up, assess the traversal and record whether changes are
|
||||||
|
worthwhile; a conclusion that no refactor is needed is valid. Make focused
|
||||||
|
changes only where the maintenance benefit justifies the code movement. Keep
|
||||||
|
cloning's typed-copy semantics distinct from projection's document-building
|
||||||
|
semantics. Preserve detached values, nil/empty distinctions, physical inline
|
||||||
|
fields, cycle/depth checks, and unresolved versus validated secret handling.
|
||||||
|
Reuse existing ownership, codec, secret, and traversal tests, adding coverage for
|
||||||
|
consequential gaps. No performance improvement is assumed.
|
||||||
|
|
||||||
|
### Questions to resolve before implementation
|
||||||
|
|
||||||
|
- After projection and schema consolidation, which clone branches still mix
|
||||||
|
responsibilities enough to benefit from extraction?
|
||||||
|
- Would a proposed helper keep allocation, recursive copying, and cycle cleanup
|
||||||
|
easier to follow together, or merely move a short loop out of sight?
|
||||||
|
- Are any small shared operations semantically identical across traversals, or
|
||||||
|
would sharing require special cases that obscure their different purposes?
|
||||||
|
- Which ownership or special-value risks, if any, lack sufficient existing test
|
||||||
|
coverage for the proposed changes?
|
||||||
|
|||||||
783
docs/roadmap/implementation.md
Normal file
783
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,783 @@
|
|||||||
|
# Consolidation Implementation Plan
|
||||||
|
|
||||||
|
Status: complete. Stages 1 through 20 are complete.
|
||||||
|
The target executor is a Luna coding agent working through one end-to-end sprint.
|
||||||
|
|
||||||
|
## Authority, Baseline, And Execution
|
||||||
|
|
||||||
|
The [consolidation roadmap](consolidation.md) owns the target state, rationale,
|
||||||
|
and settled scope. Start from `8a99e6107001c69117a41dc37bc6ea09d2910af9` plus the
|
||||||
|
commit introducing this plan. Production code is unchanged from the audited
|
||||||
|
`5959537d1adc2516e7f7e8881df72b707ec48c3f`; intervening commits consolidated the
|
||||||
|
planning documents and settled the public-API and clone decisions.
|
||||||
|
|
||||||
|
Read [development.md](../development.md), [agents.md](../policy/agents.md),
|
||||||
|
[architecture.md](../policy/architecture.md), [testing.md](../policy/testing.md),
|
||||||
|
and [documentation.md](../policy/documentation.md) as applicable. Use the code
|
||||||
|
graph for discovery and confirm source at the implementation baseline. Current
|
||||||
|
public comments and [config.md](../config.md) define behavior; this plan does not
|
||||||
|
add public obligations.
|
||||||
|
|
||||||
|
Implement only the selected consolidation. Retain the public `Load`,
|
||||||
|
`LoadLayered`, options, inputs, callbacks, source/search descriptions, inspection,
|
||||||
|
and sequence-registration APIs. Keep the root-to-engine-to-document/YAML
|
||||||
|
dependency direction. Clone traversal review/refactoring remains deferred under
|
||||||
|
[future.md](future.md#4-clone-traversal-review). Changes to shared descriptors must
|
||||||
|
preserve existing cloning behavior. This sprint does not publish a release,
|
||||||
|
update dependencies/toolchain requirements, change configuration semantics, or
|
||||||
|
introduce complexity/performance gates.
|
||||||
|
|
||||||
|
Stages are bounded work assignments, not independently shippable commits.
|
||||||
|
Intermediate stages may leave callers, tests, or compilation unfinished until a
|
||||||
|
later stage. Proceed directly through those dependencies; do not build temporary
|
||||||
|
compatibility layers solely to keep each stage compiling. Update affected tests
|
||||||
|
with their callers, and run the focused checks below when that portion builds.
|
||||||
|
Record any deferred verification accurately. The completed sprint must compile,
|
||||||
|
pass its checks, and leave no superseded plumbing. Commit completed, reviewable
|
||||||
|
units under the agent policy; stage boundaries do not require separate commits.
|
||||||
|
|
||||||
|
Preserve existing meaningful validation/processing order, diagnostics, ownership,
|
||||||
|
and limits. Private helper order and representation are not new contracts. Do
|
||||||
|
not add rejection rules, error recovery, caches, new merge strategies, another
|
||||||
|
pipeline framework, or tests for incidental helper choreography. A correctness
|
||||||
|
issue discovered during the refactor needs identifiable regression evidence and
|
||||||
|
an explicit explanation, rather than an unexplained expectation change.
|
||||||
|
|
||||||
|
## Chosen Private Design
|
||||||
|
|
||||||
|
Use the existing `loadRequest[T]` as the normalized complete-load model and
|
||||||
|
`loadInput` for each selected input. Keep generic application definitions and
|
||||||
|
the final lifecycle in `executeLoadRequest[T]`; acquisition is non-generic.
|
||||||
|
The following records are ordinary engine-private data, not extension points.
|
||||||
|
Use these names to make migrations concrete; their fields describe the selected
|
||||||
|
ownership model, not a requirement to duplicate data already available there.
|
||||||
|
|
||||||
|
| Record | Fields and ownership |
|
||||||
|
| --- | --- |
|
||||||
|
| `acquisitionPolicy` | Read-only operation snapshot of `importDefaults`, `sequencePolicy`, and `importLimits`. Snapshot caller-owned defaults at entry, preserving nil versus empty. |
|
||||||
|
| `acquisitionState` | Borrowed pointers to the load's `importAccounting` and `sourceVersionState`. One instance per load; version state no longer transports import defaults. |
|
||||||
|
| `tierAcquisitionState` | The tier's visited-resource map and shared host namespace token. Underlay groups each own a fresh instance. Existing group graph aggregation and namespace anchors retain their current owners. |
|
||||||
|
| `inputAcquisitionRequest` | `input loadInput`, source-version role, root-directive context, applicable protection rule, deferred-assembly choice, and `ordinaryOverlay string`. The last field carries only the ordinary main input's overlay-dependent validation/context timing; it is empty for underlays and layered main. |
|
||||||
|
| `importAcquisitionRequest` | The input request, parsed main source, and already computed source-version/directive processing result. It preserves the distinction between source-local processing and application-path protection. |
|
||||||
|
| `resolvedFilesystemContext` | `native *nativeNamespace` (nil means host), `hostToken *namespaceToken` where supplied, and `rootPath`, `basePath`, `mainPath` strings. Native facts use slash-relative paths; host facts use the existing canonical host paths. The record contains no open root/file or acquisition access object. |
|
||||||
|
|
||||||
|
The main private entry is
|
||||||
|
`acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic)`.
|
||||||
|
Import acquisition uses the corresponding named request with the same borrowed
|
||||||
|
policy/state/tier owners. An access-based worker receives a resolved filesystem
|
||||||
|
description and an explicitly owned `*filesystemAccess`, rather than more
|
||||||
|
positional paths and independent optional policy parameters. Retain
|
||||||
|
`assembledInput`, `acquisitionScope`, and `acquiredContribution` as the structural
|
||||||
|
handoffs; add an optional resolved-context field to `assembledInput` for reuse.
|
||||||
|
|
||||||
|
`importTraversal` borrows the policy and state references. Active routes, stack,
|
||||||
|
current item scope/prefix/fallback, and the acquired inclusion graph remain
|
||||||
|
traversal-local. Nested item traversal shares the active graph and tier repetition
|
||||||
|
state; it does not create another tier. Reusing a file in a later tier remains
|
||||||
|
valid. Do not collapse namespace identity, lexical candidate spelling, and stable
|
||||||
|
scheduling keys into one key.
|
||||||
|
|
||||||
|
Context facts are obtained at the existing points where those facts are needed.
|
||||||
|
There is no global eager context-validation pass or persistent live-handle owner.
|
||||||
|
Directory checks, canonicalization, handle opening/closing, and passive view
|
||||||
|
creation remain distinguishable. Reusing a description does not authorize
|
||||||
|
earlier checks that bypass an existing earlier error or implicit loading activity
|
||||||
|
after publication. Consumer-initiated passive filesystem operations retain their
|
||||||
|
existing contract.
|
||||||
|
|
||||||
|
For inheritance, retain both scope adapters and one executor. Use private
|
||||||
|
prepared-child/prepared-parent records for combination inputs, then an
|
||||||
|
`inheritanceTemplateResult` containing the resolved value, origins, local policy,
|
||||||
|
policy templates, and the controls/transfers/replacements/operations produced by
|
||||||
|
that template. Sequence position evidence lives in the existing operation
|
||||||
|
records. Merge callbacks mutate that local result, not the enclosing executor's
|
||||||
|
accumulator. The executor appends the result and applies existing accounting.
|
||||||
|
|
||||||
|
For reflection, keep `valueOperation` and `schemaBuilder` as their existing state
|
||||||
|
owners. Extract substantial value-kind and inline-field operations as methods;
|
||||||
|
do not introduce a generic reflection visitor or merge the two schema views.
|
||||||
|
|
||||||
|
## Validation Groups
|
||||||
|
|
||||||
|
Use the installed compatible Go toolchain and already acquired dependencies as
|
||||||
|
described in the development guide. Commands run from the repository root with
|
||||||
|
`GOTOOLCHAIN=local` and `GOPROXY=off`. These are development checks for this
|
||||||
|
refactor, not additions to the release procedure.
|
||||||
|
|
||||||
|
| Group | Commands and purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| F: facade cleanup | `go test . -run 'SourceInfo|SourceVersionCheck|ImportSelection|Facade|PublicFacadeBoundary|DependencyImportBoundary'` checks useful forward evidence and the existing public boundary. |
|
||||||
|
| A: acquisition integration | `go test ./internal/engine -run 'Load|Grouped|Source|Search|Discovery|Import|Glob|Filesystem|Native|Destination|Overlay|Resource|Version|Sequence|Collection'`; `go test . -run 'Facade|EquivalentSourceForms|Search|SourceContext|ImportDefaults|SourceVersion'`; `go test ./examples/consumers`. Covers normalization, ordinary/layered acquisition, selection, contexts, scopes, and consumer workflows. |
|
||||||
|
| I: inheritance | `go test ./internal/engine -run 'Inheritance|Inherited|Sequence|Collection|Version|History|Explain|Sensitivity|Secret'`; `go test . -run 'Inheritance|Inherited|SequenceIdentity|FinalSequence'`. Covers shared adapters and their evidence. |
|
||||||
|
| R: reflection | `go test ./internal/engine -run 'Schema|Decode|Projection|Clone|Inline|CompareDocument|Callback|Ownership|Independent|Secret'`. Covers descriptors, projection, shared cloning behavior, and ownership. |
|
||||||
|
| Final | `./scripts/check.sh` followed by `go test -race ./...`. The first owns formatting, whitespace, ordinary tests/examples/doc links, vet, and build; race validation is appropriate because state and ownership handoffs change throughout this sprint. |
|
||||||
|
|
||||||
|
Run F after Stage 2, A after Stages 9 and 13, I after Stage 16, and R after
|
||||||
|
Stage 18 when buildable. If earlier migrations still prevent compilation, repair
|
||||||
|
them at the next integration point and run the deferred group there. Run the
|
||||||
|
final checks once on the finished code/documentation; repeat only for subsequent
|
||||||
|
changes affecting their evidence. No benchmark campaign, timing threshold,
|
||||||
|
traversal-count test, extended fuzz campaign, CI wait, or manual platform check is
|
||||||
|
required by this plan. Add narrow behavior tests only for consequential gaps
|
||||||
|
identified below that existing tests do not already cover.
|
||||||
|
|
||||||
|
## Stage 1 — Establish The Starting Map (Complete)
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
1. Confirm the revision/worktree and preserve unrelated work. Read the roadmap's
|
||||||
|
settled decisions and the selected owners above.
|
||||||
|
2. Map the current callers of `selectOptions`, `assembleBaseInput...`, both
|
||||||
|
`loadImported...` ladders, `contextsForAcquiredSources`, the selected-overlay
|
||||||
|
loaders, and `applyInheritanceScopeTemplates`. Inspect test callers as well
|
||||||
|
as production callers; some old entry variants exist mainly for tests.
|
||||||
|
3. Identify the existing behavioral owners for repeated resources within/across
|
||||||
|
tiers, enabled/disabled version checking, selected/absent searches, ordinary
|
||||||
|
versus independent layered overlays, item-local helper scopes, inherited
|
||||||
|
identity/history, inline fields, and detached records.
|
||||||
|
4. Keep brief working notes or implementation progress in this plan. Reuse
|
||||||
|
existing validation evidence where applicable; establish an implicated
|
||||||
|
baseline only when needed to distinguish a pre-existing failure. Do not
|
||||||
|
generate another broad audit or blanket characterization-test suite.
|
||||||
|
|
||||||
|
**Finish:** a concrete caller/coverage map and any actual coverage gaps. No
|
||||||
|
production change or empty commit is needed for orientation alone.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): the worktree was clean at `8ab1568`, immediately
|
||||||
|
after the planning baseline `8a99e61`; the audited production baseline
|
||||||
|
`5959537` is present. `selectOptions` is called by the ordinary and grouped
|
||||||
|
load entry points. The `assembleBaseInput...` wrappers converge on
|
||||||
|
`assembleBaseInputTrackedForRole`, reached from ordinary loading, layered main
|
||||||
|
loading, and underlay-group acquisition. The tracked import wrappers converge
|
||||||
|
on `loadImportedDocumentsTrackedWithProcessing`; production callers are input
|
||||||
|
acquisition and underlay-group acquisition, while older convenience variants
|
||||||
|
also have direct test callers. `contextsForAcquiredSources` is shared by ordinary
|
||||||
|
loading, layered assembly, input acquisition, and overlay application. Selected
|
||||||
|
overlay loading is reached from ordinary overlay application and layered input
|
||||||
|
assembly. Both inheritance adapters, for ordinary and identified sequences,
|
||||||
|
feed `applyInheritanceScopeTemplates`.
|
||||||
|
|
||||||
|
Existing behavioral owners cover repeated/cyclic resources and cross-tier reuse
|
||||||
|
(`internal/engine/imports_test.go`, `facade_equivalence_test.go`); default and
|
||||||
|
enabled source-version modes (`internal/engine/source_version_test.go`,
|
||||||
|
`facade_source_version_mode_test.go`); selected/absent discovery
|
||||||
|
(`internal/engine/discovery_load_test.go`, `facade_discovery_test.go`); ordinary
|
||||||
|
and layered overlays (`internal/engine/selected_overlay_test.go`,
|
||||||
|
`facade_layered_test.go`); item-local helper scopes (`internal/engine/imports_test.go`,
|
||||||
|
`facade_import_lifecycle_test.go`); inheritance identity/history
|
||||||
|
(`internal/engine/inheritance_identity_history_test.go`,
|
||||||
|
`internal/engine/inheritance_history_test.go`); inline fields
|
||||||
|
(`internal/engine/schema_test.go`, `internal/engine/projection_test.go`); and
|
||||||
|
detached results and callback ownership (`internal/engine/metadata_test.go`,
|
||||||
|
`internal/engine/callback_ownership_test.go`, `facade_diff_test.go`). No specific
|
||||||
|
coverage gap surfaced in this map; later focused checks remain assigned in the
|
||||||
|
validation groups above.
|
||||||
|
|
||||||
|
## Stage 2 — Remove Unused Reverse Bridges And Share Real Facade Conversion (Complete)
|
||||||
|
|
||||||
|
**Owners:** root `conversion.go`, `records_test.go`, `lifecycle.go`, `layered.go`,
|
||||||
|
and engine `records.go`.
|
||||||
|
|
||||||
|
1. Reconfirm callers, then delete `toEngineSourceInfo`, `toEngineOrigin`,
|
||||||
|
`toEngineSourceVersionCheck`, and `toEngineImportSelections`, plus
|
||||||
|
`SourceInfoWithPlacement`, `SourceInfoWithImportSelections`,
|
||||||
|
`importSelectorFromString`, and `importSelectionOutcomeFromString` when their
|
||||||
|
only remaining purpose is the deleted chain.
|
||||||
|
2. Rewrite the three reverse-round-trip record tests using genuine engine output
|
||||||
|
and forward conversion/public inspection. Retain placement, version-evidence,
|
||||||
|
selection-evidence, nil/empty, and detached-copy protection. Engine test
|
||||||
|
fixtures can use existing loading APIs; do not add production fixture setters.
|
||||||
|
3. Extract one root `toEngineOverrides` helper and use it from both option
|
||||||
|
converters. Reuse `toEngineInput`, filesystem, path, definition, and default
|
||||||
|
conversion where already shared. Preserve slice ownership and nil/empty
|
||||||
|
behavior; do not make conversion interpret/validate input values.
|
||||||
|
4. Retain reverse diagnostic conversions used by `WriteDiagnostics`, forward
|
||||||
|
source/origin inspection conversion, and all public named types.
|
||||||
|
|
||||||
|
**Finish:** the unnecessary cross-package inverse interface is gone and real
|
||||||
|
conversion is shared without a new transport layer. Run F.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): removed the unused reverse source-record bridge
|
||||||
|
and engine setters, replaced synthetic round-trip fixtures with engine loads,
|
||||||
|
and shared override conversion across ordinary and layered options. Validation
|
||||||
|
group F passed with `GOTOOLCHAIN=local GOPROXY=off go test . -run
|
||||||
|
'SourceInfo|SourceVersionCheck|ImportSelection|Facade|PublicFacadeBoundary|DependencyImportBoundary' -count=1`.
|
||||||
|
|
||||||
|
## Stage 3 — Normalize Preflight Without Fabricated Options (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `options.go`, ordinary entry in `load.go`, grouped entry in
|
||||||
|
`load_request.go`, and their option tests.
|
||||||
|
|
||||||
|
1. Extract non-generic input normalization/validation over `loadInput`, returning
|
||||||
|
the normalized input plus diagnostics. Pass the ordinary overlay name only
|
||||||
|
where its context requirements apply. Keep discovery-description validation,
|
||||||
|
placement checks, normalized destinations, and filesystem option checks at
|
||||||
|
their existing pre-acquisition boundary.
|
||||||
|
2. Separate load-wide import-default validation, version-rule/mode validation,
|
||||||
|
and sequence-registration compilation from per-input checks. Share their
|
||||||
|
mechanisms between entries without adding a second interpretation of options.
|
||||||
|
3. Keep the public/engine entry arity handling and each entry's existing error
|
||||||
|
classification. Ordinary loading still translates its one selected options
|
||||||
|
value; layered loading validates main, all underlay inputs, and its independent
|
||||||
|
overlay context in the existing meaningful order. Preserve canonical diagnostic
|
||||||
|
accumulation and early returns, including invalid options before reads.
|
||||||
|
4. Replace the grouped entry's constructed `Options[T]` values with direct
|
||||||
|
normalization of `loadInput`; set the effective protection rule from the
|
||||||
|
normalized main placement. Leave typed application behavior in the definition.
|
||||||
|
5. Remove `importDefaultsValidated` and preflight-only forwarding machinery when
|
||||||
|
callers migrate. Update tests through the entry/helper that owns the behavior,
|
||||||
|
rather than keeping `selectOptions` solely as a test compatibility surface.
|
||||||
|
|
||||||
|
**Finish:** both entries produce validated `loadRequest[T]` values without using
|
||||||
|
the ordinary options type to validate grouped inputs. Acquisition may still use
|
||||||
|
old options until Stage 6; do not broaden this stage into that migration.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): added shared normalization over `loadInput`, moved
|
||||||
|
load-wide defaults, source-version, and sequence-registration validation into
|
||||||
|
shared settings preparation, and removed `selectOptions` plus its validation
|
||||||
|
flag from grouped preflight. `GOTOOLCHAIN=local GOPROXY=off go test ./...` passed.
|
||||||
|
|
||||||
|
## Stage 4 — Express Input Acquisition With Non-Generic Requests (Complete)
|
||||||
|
|
||||||
|
**Owners:** new engine `acquisition.go` and existing `load_input.go`.
|
||||||
|
|
||||||
|
1. Define `acquisitionPolicy`, `acquisitionState`, `tierAcquisitionState`,
|
||||||
|
`inputAcquisitionRequest`, and `importAcquisitionRequest` as selected above.
|
||||||
|
Reuse existing enums, policy records, and accounting; introduce no new public
|
||||||
|
controls or duplicate budget counter.
|
||||||
|
2. Replace the generic `assembleBaseInputTrackedForRole` implementation with
|
||||||
|
`acquireInput`. Translate its references to source, imports, filesystem,
|
||||||
|
placement, role, directive context, version rule, policy, and deferred
|
||||||
|
assembly into explicit request/policy/state fields.
|
||||||
|
3. Keep its phases visible: search selection; parse and failed-source evidence;
|
||||||
|
disabled-directive checks; source-local version/directive processing; import
|
||||||
|
acquisition or single-input placement; accounting/repetition registration;
|
||||||
|
context handoff. Small helpers may isolate version/directive preparation.
|
||||||
|
4. Preserve discovery resolution once per input consumption and the acquired
|
||||||
|
concrete source on both success and failure. Keep mapping diagnostic
|
||||||
|
destination projection and the ordinary-overlay context deferral unchanged.
|
||||||
|
5. Retain `assembledInput`'s existing structural/evidence fields. Do not merge
|
||||||
|
parsed/local payloads with placed documents or eagerly compose deferred input.
|
||||||
|
|
||||||
|
**Finish:** the shared input-acquisition implementation no longer depends on
|
||||||
|
`Options[T]` or an application type. Call-site and import-worker migration is
|
||||||
|
completed by Stages 5–6; temporary non-compilation is acceptable.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): introduced the non-generic acquisition request and
|
||||||
|
policy/state/tier records, moved the input-acquisition implementation into
|
||||||
|
`acquireInput`, and added the resolved-context handoff field. The focused engine
|
||||||
|
test command currently cannot compile because the old assembly callers in
|
||||||
|
`load_input.go` and `load_request.go` are scheduled to migrate in Stage 6; test
|
||||||
|
verification is deferred until that integration.
|
||||||
|
|
||||||
|
## Stage 5 — Consolidate Import Requests And Default Ownership (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine import entry points, `importTraversal`, `source_version.go`,
|
||||||
|
and tests that directly invoke the old import variants.
|
||||||
|
|
||||||
|
1. Replace the `loadImportedDocumentsTracked...` ladder with
|
||||||
|
`acquireImportedInput` using `importAcquisitionRequest` and the shared
|
||||||
|
policy/state/tier owners. Replace the access-based ladder with one worker
|
||||||
|
receiving the same request plus explicit access/resolved facts. Keep only
|
||||||
|
genuinely distinct convenience entries, not one wrapper per optional field.
|
||||||
|
2. Move `importDefaults` out of `sourceVersionState`. Resolve declarations from
|
||||||
|
`acquisitionPolicy.importDefaults`, including nested item scopes. Import
|
||||||
|
defaults must work independently of whether a version rule/state is active.
|
||||||
|
Preserve enabled version processing and its separate local/effective rules.
|
||||||
|
3. Make traversal references to aggregate accounting, version evidence, tier
|
||||||
|
repetition, and immutable policies point to their existing operation owners.
|
||||||
|
Keep active-route tracking, scope switching, and inclusion edges local to
|
||||||
|
the traversal. Do not allocate a fresh visited map inside a nested item scope.
|
||||||
|
4. Preserve nil version-state behavior used by lower-level operations and the
|
||||||
|
existing default processing branch. Replace optional positional/variadic
|
||||||
|
policy passing with explicit inputs for meaningful operation distinctions.
|
||||||
|
Private test entry spellings do not need compatibility wrappers; preserve
|
||||||
|
their relevant behavioral cases through the resulting operation.
|
||||||
|
5. Migrate direct import tests to named inputs. Small test-only constructors may
|
||||||
|
supply default state/policy values when this reduces repetitive fixtures.
|
||||||
|
Keep their behavior assertions intact.
|
||||||
|
|
||||||
|
**Finish:** the import request surface has named cohesive inputs; defaults no
|
||||||
|
longer travel through version processing; no forwarding ladder remains merely
|
||||||
|
to supply another nil/default positional parameter.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): replaced the import entry ladders with named
|
||||||
|
requests and one access worker, moved defaults to acquisition policy, and
|
||||||
|
retargeted direct import tests. The focused engine test command cannot compile
|
||||||
|
yet because the remaining ordinary and grouped assembly callers are scheduled
|
||||||
|
for Stage 6; verification is deferred until that integration. `git diff --check`
|
||||||
|
passed.
|
||||||
|
|
||||||
|
## Stage 6 — Wire Ordinary And Grouped Lifecycle To Acquisition (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `load.go`, `load_request.go`, `load_input.go`, related tests.
|
||||||
|
|
||||||
|
1. Construct the load's acquisition policy and mutable state once in
|
||||||
|
`executeLoadRequest`. Keep the definition and schema/callback lifecycle there.
|
||||||
|
2. Call `acquireInput` for main using its ordinary/layered directive context,
|
||||||
|
main version role, current protection rule, and deferred assembly. Preserve
|
||||||
|
the existing schema-build and version-check timing, including the different
|
||||||
|
default/enabled source-version paths.
|
||||||
|
3. Have `acquireUnderlayGroupScopes` use the same load policy/accounting/version
|
||||||
|
state and one fresh tier state per group. Remove `Options[struct{}]`
|
||||||
|
construction. Preserve explicit namespace anchors and graph reanchoring.
|
||||||
|
4. Main acquisition still precedes underlays. Strict assembly still follows
|
||||||
|
the existing dependency-ready scopes and lower-tier identity information.
|
||||||
|
Keep group-local repetition separate from load-wide limits, and retain the
|
||||||
|
existing selected-overlay duplicate-source checks.
|
||||||
|
5. Replace the ordinary `applyInputLayers` options argument with the precise
|
||||||
|
main/overlay/protection/policy inputs it uses. A named overlay request will
|
||||||
|
complete that migration in Stage 9. Remove remaining production
|
||||||
|
`assembleBaseInput...` variants and the acquisition-only `sequencePolicy`,
|
||||||
|
`deferAssembly`, and `directiveContext` fields from `Options`. Retain the
|
||||||
|
placement, destination, and import-default fields used by facade translation.
|
||||||
|
6. Retain real lower-level test/benchmark capabilities with direct request
|
||||||
|
construction or test-only adapters. Do not delete meaningful source/limit
|
||||||
|
cases to make removed helpers disappear.
|
||||||
|
|
||||||
|
**Finish:** no executor or underlay caller reconstructs broad/generic options
|
||||||
|
for acquisition. The shared path is wired end to end; context and import-flow
|
||||||
|
extraction continue in later stages.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): `executeLoadRequest` now creates one acquisition
|
||||||
|
policy/state and acquires the main input through `acquireInput`; underlay groups
|
||||||
|
reuse those load owners with a fresh tier state. Removed the generic base-input
|
||||||
|
assembly variants and the acquisition-only fields from `Options`, and adapted
|
||||||
|
direct tests to construct named requests. A search-source regression was caught
|
||||||
|
and fixed during verification. The focused engine selection, facade selection,
|
||||||
|
consumer example tests, and `GOTOOLCHAIN=local GOPROXY=off go test ./...` passed.
|
||||||
|
|
||||||
|
## Stage 7 — Separate Resolved Filesystem Facts From Access (Complete)
|
||||||
|
|
||||||
|
**Owners:** new engine `filesystem_context.go`, `filesystem.go`,
|
||||||
|
`filesystem_access.go`.
|
||||||
|
|
||||||
|
1. Add `resolvedFilesystemContext` with the fields selected above. Reuse native
|
||||||
|
namespace/token identity; do not compare arbitrary `fs.FS` values or equate
|
||||||
|
equal labels with equal namespaces.
|
||||||
|
2. Extract host and native fact derivation from `importFilesystem` and
|
||||||
|
`nativeFilesystemAccess`. Prefer a selected `SourceInfo.Path` when it already
|
||||||
|
supplies canonical source identity. Preserve source-derived file bases,
|
||||||
|
explicit in-memory roots/bases, native slash paths, and the root-only absent
|
||||||
|
host default.
|
||||||
|
3. Keep separate functions for the directory checks and opening access that
|
||||||
|
currently occur at each operation. A returned description does not mean
|
||||||
|
every possible check has run. Do not add validation-state flags or an
|
||||||
|
all-purpose eager resolver with stronger acceptance rules.
|
||||||
|
4. Adapt the import worker to consume resolved facts and explicitly own its
|
||||||
|
access until the existing acquisition handoff closes it. Keep close-error
|
||||||
|
reporting and primary-error precedence unchanged. Native access borrows its
|
||||||
|
caller filesystem and must not close it.
|
||||||
|
5. Keep any old derivation adapters only while genuine callers migrate in
|
||||||
|
Stages 8–9; remove them at the end of that migration.
|
||||||
|
|
||||||
|
**Finish:** common context facts have one private representation while validation
|
||||||
|
timing and live resource ownership remain explicit.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): added shared host/native filesystem descriptions,
|
||||||
|
separated directory validation and access creation, and migrated import
|
||||||
|
acquisition to pass resolved facts alongside its owned access. The prior
|
||||||
|
derivation adapters remain for the validator and overlay migration. Focused
|
||||||
|
acquisition tests, `GOTOOLCHAIN=local GOPROXY=off go test ./...`, and
|
||||||
|
`git diff --check` passed.
|
||||||
|
|
||||||
|
## Stage 8 — Carry Context Facts Through Input And Passive Views (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `load_input.go`, `source_context.go`, `load_request.go`, and
|
||||||
|
the ordinary context backfill in `load.go`.
|
||||||
|
|
||||||
|
1. Add the optional resolved-context field to `assembledInput`. Populate it
|
||||||
|
from import acquisition or the existing non-import context validation point.
|
||||||
|
When context work is deferred for an ordinary overlay, leave it deferred.
|
||||||
|
2. Replace the source-kind derivation branches of `contextsForAcquiredSources`
|
||||||
|
with `contextsForResolvedSources`: consume resolved facts and source records,
|
||||||
|
construct the existing rooted host/native passive view, and compute each
|
||||||
|
source's relative base using the appropriate path rules.
|
||||||
|
3. Share relative-base/containment calculation where applicable; retain each
|
||||||
|
acquired source's own path and source-ID association. Do not substitute the
|
||||||
|
first source record for the selected main file or attach all values to main.
|
||||||
|
4. Migrate normal input acquisition, underlay contexts, and ordinary context
|
||||||
|
backfill. Resolve only still-missing facts at the same lifecycle point as
|
||||||
|
before. Retained descriptions/views never capture `filesystemAccess` or
|
||||||
|
`*os.Root`; the existing passive `ResourceContext` contract stays unchanged.
|
||||||
|
5. Retain failure evidence, root-only absent host context, discovered native
|
||||||
|
outcomes, and deferred search-base behavior. Update focused context tests
|
||||||
|
only where private function signatures changed.
|
||||||
|
|
||||||
|
**Finish:** ordinary/underlay acquisition and passive views use the common facts;
|
||||||
|
selected-overlay callers are the remaining migration in Stage 9.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): input acquisition now retains resolved filesystem
|
||||||
|
facts and builds passive contexts from those facts for ordinary and underlay
|
||||||
|
sources. Migrated the ordinary backfill and reordered-source context test. The
|
||||||
|
selected-overlay adapters remain for Stage 9. Focused context/acquisition tests,
|
||||||
|
`GOTOOLCHAIN=local GOPROXY=off go test ./...`, and `git diff --check` passed.
|
||||||
|
|
||||||
|
## Stage 9 — Migrate Selected Overlays Without Merging Their Semantics (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `selected_overlay.go`, ordinary `applyInputLayers`, and
|
||||||
|
`assembleLayeredInput`.
|
||||||
|
|
||||||
|
1. Define a private `selectedOverlayRequest` containing the overlay name,
|
||||||
|
resolved filesystem description, protection rule, sequence policy, and
|
||||||
|
existing limits/version state. Derive context at the caller's existing
|
||||||
|
lifecycle point. Pass assembly and source evidence explicitly to the worker.
|
||||||
|
2. Route ordinary overlays through the main source's context; route layered
|
||||||
|
overlays through their independent `OverlayFilesystem`. Use named operation
|
||||||
|
inputs instead of fabricating a `sourceBytes`/empty parsed-main pair merely
|
||||||
|
to select a context branch. Preserve the actual absent-host root behavior.
|
||||||
|
3. Return or retain the resolved description used by overlay acquisition and
|
||||||
|
build overlay passive context from it. Reuse main facts only when that
|
||||||
|
ordinary operation previously used the same root. Context facts do not share
|
||||||
|
a live handle between the main import traversal and the later overlay read.
|
||||||
|
4. Keep the production collection-aware overlay path. Migrate meaningful
|
||||||
|
lower-level tests to that path with explicit policy/limits, retaining their
|
||||||
|
budget, version, and failure assertions. Delete the test-only legacy branch
|
||||||
|
selected by missing variadic policy and its exclusive adapters, rather than
|
||||||
|
treating optional argument count as a contract. The baseline production entry
|
||||||
|
points all supply the policy argument.
|
||||||
|
5. Keep overlay source IDs, duplicate detection, version protection, directory
|
||||||
|
validation, read/close errors, aggregate accounting, and post-read application
|
||||||
|
order. Remove superseded context derivation and overlay-forwarding adapters.
|
||||||
|
|
||||||
|
**Finish:** all acquisition/context callers share facts and named inputs while
|
||||||
|
ordinary and independent layered overlay semantics remain distinct. Run A when
|
||||||
|
buildable; carry any genuine remaining migration dependencies to the next
|
||||||
|
integration opportunity instead of adding scaffolding or stopping the sprint.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): introduced the named overlay request and migrated
|
||||||
|
ordinary and independent layered overlay acquisition to resolved facts plus
|
||||||
|
explicit access. Replaced the variadic compatibility branch with the existing
|
||||||
|
collection-aware path, removed old context adapters, and retained overlay
|
||||||
|
validation timing, source evidence, budgets, version protection, and close
|
||||||
|
reporting. Validation group A, consumer examples, the full Go suite, and
|
||||||
|
`git diff --check` passed.
|
||||||
|
|
||||||
|
## Stage 10 — Give Import Responsibilities Focused Files (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `imports.go` and new `import_acquisition.go`,
|
||||||
|
`import_scope.go`, `import_projection.go`.
|
||||||
|
|
||||||
|
1. Put the request entry/access worker and traversal admission/orchestration in
|
||||||
|
`import_acquisition.go`. Keep `imports.go` as the small common owner for
|
||||||
|
limits, accounting/route/traversal data, and shared import diagnostics.
|
||||||
|
2. Move `composeAcquiredInputWithPolicies`, `composeAcquiredScopeWithPolicy`,
|
||||||
|
pending-item composition, placement reservation, and their item-scope
|
||||||
|
assembly helpers into `import_scope.go`.
|
||||||
|
3. Move nested-origin/policy/evidence projection, acquired source destinations,
|
||||||
|
version-evidence projection, sequence-prefix helpers, and diagnostic-path
|
||||||
|
projection into `import_projection.go`. Scheduling and graph identity remain
|
||||||
|
with their existing graph/scheduler owners.
|
||||||
|
4. Make the acquisition-to-closed-resources-to-assembly handoff visible. Keep
|
||||||
|
evidence projection based on existing composition applications/position maps;
|
||||||
|
file movement does not authorize another merge, full-tree replay, or scan of
|
||||||
|
every unrelated policy for each record.
|
||||||
|
|
||||||
|
**Finish:** maintainers can locate acquisition, local strict assembly, and evidence
|
||||||
|
work separately. Keep this stage structural; parse/admission extraction follows.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): moved request acquisition and traversal admission to
|
||||||
|
`import_acquisition.go`, local strict composition and item-scope assembly to
|
||||||
|
`import_scope.go`, and origin, destination, version-evidence, and diagnostic-path
|
||||||
|
projection to `import_projection.go`. Shared limits, traversal data, and import
|
||||||
|
diagnostics remain in `imports.go`. `./scripts/check.sh` passed.
|
||||||
|
|
||||||
|
## Stage 11 — Extract Import Declaration Parsing Operations (Complete)
|
||||||
|
|
||||||
|
**Owner:** engine `import_spec.go`.
|
||||||
|
|
||||||
|
1. Retain the outer mapping check, exactly-one-selector check, source-order field
|
||||||
|
walk, applicability checks, and final selector parsing in
|
||||||
|
`parseImportDeclarationSpec`.
|
||||||
|
2. Extract boolean assignment, wildcard-set parsing/validation (shared by
|
||||||
|
`pattern` and `exclude`), placement parsing, and selector-value parsing into
|
||||||
|
ordinary private helpers. Reuse existing `parseStringSet`, authored-member
|
||||||
|
sites, and destination/depth parsers.
|
||||||
|
3. Preserve unspecified pointers and set flags, especially nil versus empty
|
||||||
|
pattern lists. Keep error paths/locations tied to the authored selector or
|
||||||
|
list member. Do not reorder consequential checks or replace the selector
|
||||||
|
matrix with a configurable parser registry.
|
||||||
|
|
||||||
|
**Finish:** field decoding is readable without changing declaration syntax,
|
||||||
|
selector rules, default resolution, or diagnostics. Keep behavior tests as the
|
||||||
|
owner; no helper-by-helper assertion inventory is needed.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): extracted boolean assignment, shared wildcard set
|
||||||
|
assignment, placement parsing, and selector-value parsing into private helpers.
|
||||||
|
The declaration parser retains its validation ordering, authored member sites,
|
||||||
|
and nil-versus-empty presence behavior. Import-focused tests and
|
||||||
|
`./scripts/check.sh` passed.
|
||||||
|
|
||||||
|
## Stage 12 — Extract Directory Entry Admission And Descent (Complete)
|
||||||
|
|
||||||
|
**Owner:** engine `import_selection.go`, using existing directory/link helpers.
|
||||||
|
|
||||||
|
1. Keep root normalization/deduplication, root ordering, the explicit traversal
|
||||||
|
stack, candidate union, sorting, exclusion, and required-empty decision visible
|
||||||
|
in `selectDirectoryImportCandidates`.
|
||||||
|
2. Extract coherent entry inspection and descent preparation. Return a small
|
||||||
|
result identifying skip, admitted file, or directory frame to enter, plus a
|
||||||
|
diagnostic when appropriate. Keep stack/active-directory mutation with the
|
||||||
|
traversal so its lifetime is visible; do not invent a general visitor API.
|
||||||
|
3. Preserve hidden/follow/depth behavior, distinct lexical candidates versus
|
||||||
|
canonical access identity, resource-entry accounting, root-error handling,
|
||||||
|
and active-path cycle detection. A later non-active alias remains eligible;
|
||||||
|
exclusions still do not suppress discovery errors.
|
||||||
|
4. Keep globs and first-match main-file discovery on their existing mechanisms.
|
||||||
|
Share a helper only where its existing admission semantics actually coincide.
|
||||||
|
|
||||||
|
**Finish:** substantial entry/descent policy is separated from stack orchestration
|
||||||
|
without changing the selection algorithm or adding repeated root walks.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): extracted entry inspection and file/directory
|
||||||
|
admission into a result-returning helper. The selector retains root handling,
|
||||||
|
stack and active-path mutation, candidate union, sorting, exclusions, and the
|
||||||
|
required-empty decision. Directory-focused tests and `./scripts/check.sh` passed.
|
||||||
|
|
||||||
|
## Stage 13 — Extract Imported-Source Admission From Recursion (Complete)
|
||||||
|
|
||||||
|
**Owner:** engine `import_acquisition.go` after Stage 10.
|
||||||
|
|
||||||
|
1. Keep `importTraversal.visitPath` as the visible coordinator of containment,
|
||||||
|
canonical identity, cycle/repetition checks, read/parse, directive/version
|
||||||
|
processing, placement, and recursive declaration traversal.
|
||||||
|
2. Extract source admission/read-and-process results into a private
|
||||||
|
`admittedImport` carrying canonical resource/node identity, source record,
|
||||||
|
processed payload/declarations, and processing evidence. Return an explicit
|
||||||
|
optional-missing outcome; do not represent a read failure as absence.
|
||||||
|
3. Preserve when source records are appended, rolled back for optional missing
|
||||||
|
input, and retained for failures; preserve source IDs, accounting increments,
|
||||||
|
version-callback ordering, and inclusion-edge creation on repetition/cycles.
|
||||||
|
4. Pass the admitted record to mapping/item placement helpers rather than
|
||||||
|
repeating its payload, declarations, and processing evidence as parallel
|
||||||
|
arguments to `visitItemSource`.
|
||||||
|
5. Factor route push/pop and item-scope save/restore only where it makes their
|
||||||
|
shared lifetime clearer. Keep active graph state across item scopes and
|
||||||
|
reserve placement nodes once. Item helpers complete before outer placement;
|
||||||
|
they are strict local peers, not a precedence layer.
|
||||||
|
|
||||||
|
**Finish:** admission decisions and recursion have clear handoffs, with one
|
||||||
|
read/process path and the original scheduling/evidence algorithms. Run A again
|
||||||
|
because Stages 10–13 changed the import implementation after the first checkpoint.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): introduced an explicit admitted-import record and
|
||||||
|
optional-missing outcome for source read/parse/version processing. `visitPath`
|
||||||
|
retains containment, identity/cycle checks, graph updates, and recursive
|
||||||
|
placement coordination; item placement receives the admitted record directly.
|
||||||
|
Validation group A and `./scripts/check.sh` passed.
|
||||||
|
|
||||||
|
## Stage 14 — Extract Inheritance Child And Parent Preparation (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `inheritance_schedule.go`, new `inheritance_prepare.go`, and
|
||||||
|
existing scope/policy helpers.
|
||||||
|
|
||||||
|
1. Extract preparation of the child's directive-free payload, direct origins,
|
||||||
|
local policy templates, and optional control/control-origin evidence.
|
||||||
|
2. Extract parent preparation: clone/project policies, lower them against the
|
||||||
|
child's claims, exclude parent identity from the inherited payload, and
|
||||||
|
prepare the separate parent history snapshot where currently required.
|
||||||
|
Keep effective policy templates and transfer construction available to the
|
||||||
|
combination result rather than reconstructing them afterward.
|
||||||
|
3. Use focused prepared-child/prepared-parent records of these related values.
|
||||||
|
Borrow authored/resolved parent inputs read-only; retain existing clone points
|
||||||
|
for mutable payloads and history. Keep the current preliminary lower-bound
|
||||||
|
budget decisions at the executor's existing point before materialization.
|
||||||
|
4. Keep mapping and identified-sequence adapters, the stable Kahn scheduler, and
|
||||||
|
the parentless/cancelled path semantics intact. Do not flatten nested directives
|
||||||
|
before their destination-local evaluation.
|
||||||
|
|
||||||
|
**Finish:** the executor no longer embeds child/control and parent/policy
|
||||||
|
preparation. Actual recursive combination/evidence moves in Stage 15.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): extracted child/control and parent/policy preparation
|
||||||
|
into focused records in `inheritance_prepare.go`. The executor retains its
|
||||||
|
preliminary budget checks, scheduler, and combination path, while history and
|
||||||
|
transfer inputs stay available to combination. Engine inheritance coverage and
|
||||||
|
facade inheritance tests passed.
|
||||||
|
|
||||||
|
## Stage 15 — Return One Inheritance Combination Result (Complete)
|
||||||
|
|
||||||
|
**Owners:** new engine `inheritance_combine.go`, `inheritance_collection.go`,
|
||||||
|
and the shared executor.
|
||||||
|
|
||||||
|
1. Add `inheritanceTemplateResult` as described above and extract the existing
|
||||||
|
parent/child combination into a helper receiving prepared inputs and the
|
||||||
|
application policy. Parentless templates return the same result shape.
|
||||||
|
2. Keep `mergeRecursiveMappings` and `combineSequence` as the combination
|
||||||
|
mechanisms. Their callbacks update local origins/policy and local replacement
|
||||||
|
evidence. Have `applyInheritanceSequenceOrigins` return its replacement slice
|
||||||
|
instead of taking the whole enclosing scope result; append that slice to the
|
||||||
|
local template result while retaining its updates to the supplied origin maps.
|
||||||
|
3. Derive sequence history, transferred origins, retired/replaced policies,
|
||||||
|
identity preservation, and the transfer operation from that same combination.
|
||||||
|
Preserve `historyParent` and all policy/identity fields on the existing
|
||||||
|
`inheritanceOperation`; do not rerun a merge or project values to recover them.
|
||||||
|
4. Keep sequence validation, diagnostic canonicalization, location assignment,
|
||||||
|
replacement order, and no-parent cloning behavior at their established
|
||||||
|
semantic points. Keep child identity absent from inherited-parent history but
|
||||||
|
present with its own origin in the effective child.
|
||||||
|
|
||||||
|
**Finish:** merge callbacks no longer mutate the executor accumulator. The
|
||||||
|
resolved template and all evidence travel together for Stage 16 integration.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): added `inheritanceTemplateResult` and moved the
|
||||||
|
parent/child merge and evidence construction into `combineInheritanceTemplate`.
|
||||||
|
Callbacks now update the local origins, policies, and replacement evidence;
|
||||||
|
sequence-origin handling returns replacement records to the template result.
|
||||||
|
Parentless templates use the same result shape. Engine and facade inheritance
|
||||||
|
tests passed.
|
||||||
|
|
||||||
|
## Stage 16 — Finish The Inheritance Executor And Accounting (Complete)
|
||||||
|
|
||||||
|
**Owner:** engine `inheritance_schedule.go` and focused finalization helpers.
|
||||||
|
|
||||||
|
1. Reduce the executor to preparation, budget precheck, combination, accumulation,
|
||||||
|
and final rebuild. Append each template's control before its transfer/history
|
||||||
|
in the same order as before, and update the indexed resolved value/origin/
|
||||||
|
policy/template state once per template.
|
||||||
|
2. Extract retained-history charging for one template's returned operations and
|
||||||
|
replacements, reusing the same `retainedNodeCounter` across the scope as
|
||||||
|
before. Preserve pointer-based shared-node accounting, lower-bound checks,
|
||||||
|
scope-slot overhead, final expansion check, limits, and diagnostics.
|
||||||
|
3. Extract final rebuilding and scope policy/origin projection where it clarifies
|
||||||
|
the coordinator. Reuse `newInheritancePolicyIndex` once for scope localization;
|
||||||
|
do not return to scanning all policy entries for every slot.
|
||||||
|
4. Confirm both adapters and nested walking consume the same output. Retain
|
||||||
|
source-version movement, sensitivity, independent snapshots, assembled order,
|
||||||
|
cancellation, and policy retirement on actual replacement.
|
||||||
|
|
||||||
|
**Finish:** one readable executor coordinates explicit value/policy/evidence
|
||||||
|
handoffs, with no parallel inheritance implementation. Run I.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): extracted per-template retained-history charging and
|
||||||
|
scope rebuild/origin/policy projection into `inheritance_finalize.go`. The shared
|
||||||
|
counter, budget checks, final expansion check, and one policy index remain intact.
|
||||||
|
Both mapping and identified-sequence adapters still use the shared executor.
|
||||||
|
Validation group I passed.
|
||||||
|
|
||||||
|
## Stage 17 — Extract Substantial Projection Handlers (Complete)
|
||||||
|
|
||||||
|
**Owners:** engine `projection.go`, optionally new `projection_collection.go`.
|
||||||
|
|
||||||
|
1. Keep `projectConfiguration`'s schema/type guard and one `valueOperation`.
|
||||||
|
Keep `project`'s depth check, pointer/dynamic dispatch, and scalar conversion
|
||||||
|
choices visible; delegate structs, maps, and sequences to named methods.
|
||||||
|
2. Move struct member/inline flattening, collision checks, mapping-key charging,
|
||||||
|
and flattened-field/inline-map secret classification into `projectStruct`.
|
||||||
|
Preserve declaration order and the current decrement for a flattened
|
||||||
|
container that is not retained in the output.
|
||||||
|
3. Move deterministic sorted-map traversal into `projectMap`; keep its cycle
|
||||||
|
entry active until all recursive children complete. Move slice/array
|
||||||
|
construction into `projectSequence`, preserving nil-slice handling and the
|
||||||
|
slice-only cycle guard.
|
||||||
|
4. All recursive children call the same operation's dispatcher. Avoid duplicate
|
||||||
|
node charging/depth increments or early cycle release introduced by extraction.
|
||||||
|
Preserve float/text/duration/secret scalar handling and error paths.
|
||||||
|
|
||||||
|
**Finish:** dispatch and substantial handlers are separately readable, using the
|
||||||
|
original operation state. Do not refactor `clone`, `decodeValue`, or
|
||||||
|
`valueBuilder.build` as an additional cleanup project.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): delegated struct, map, and sequence projection to
|
||||||
|
methods on the existing `valueOperation`. The dispatcher retains depth checks,
|
||||||
|
pointer/dynamic handling, and scalar conversion; struct order, map sorting,
|
||||||
|
flattened-node charging, nil slices, and cycle guards remain in their original
|
||||||
|
paths. Validation group R passed.
|
||||||
|
|
||||||
|
## Stage 18 — Separate Schema Field Discovery And Assembly (Complete)
|
||||||
|
|
||||||
|
**Owner:** engine `schema.go`, optionally new `schema_fields.go`.
|
||||||
|
|
||||||
|
1. Keep `describeFields` as declaration-order enumeration, exported/ignored/tag
|
||||||
|
decisions, and dispatch to ordinary or inline handling.
|
||||||
|
2. Extract inline pointer-chain inspection and inline-struct/map assembly,
|
||||||
|
retaining recursion/building-state checks and the existing codec-versus-
|
||||||
|
structural distinction. Reuse the one builder/cache.
|
||||||
|
3. Make ordinary registration and inline flattening visibly maintain both
|
||||||
|
`members` and flattened `fields`/`fieldByName`/`inlineMap`. Preserve index
|
||||||
|
paths, nested secret propagation, ambiguity paths, and the single-inline-map
|
||||||
|
rule. Share registration helpers only for genuinely identical updates.
|
||||||
|
4. Preserve descriptor reuse and the physical-member view used by typed cloning.
|
||||||
|
Do not unify these views or add alternate field/tag policy. Existing schema,
|
||||||
|
projection, decoding, and cloning expectations remain the behavior baseline.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): kept declaration-order enumeration, exported and
|
||||||
|
ignored-field decisions, and tag parsing in `describeFields`; moved ordinary
|
||||||
|
registration, inline pointer inspection, and inline struct/map assembly into
|
||||||
|
focused helpers in `schema_fields.go`. Physical members remain distinct from
|
||||||
|
flattened fields and inline-map metadata. Validation group R passed.
|
||||||
|
|
||||||
|
**Finish:** field discovery and descriptor assembly have explicit responsibilities
|
||||||
|
without a new registration framework. Run R for both Stages 17–18.
|
||||||
|
|
||||||
|
## Stage 19 — Update Consumer And Internal Navigation (Complete)
|
||||||
|
|
||||||
|
**Owners:** `docs/consumers/README.md`, `docs/internal/overview.md`,
|
||||||
|
`docs/internal/imports.md`, and affected links/test-owner entries in
|
||||||
|
`docs/development.md`.
|
||||||
|
|
||||||
|
1. Add a concise consumer choice guide: ordinary `Load` for one selected source,
|
||||||
|
explicit discovery as a source usable by either entry, and `LoadLayered` for
|
||||||
|
underlay groups. Distinguish input-owned placement/import/filesystem choices
|
||||||
|
from load-wide definition/default/version/override choices and independent
|
||||||
|
layered overlay context. Link current API/config owners for exact rules.
|
||||||
|
2. Link the existing simple README example, bundled-file/layered examples,
|
||||||
|
import-focused prompt catalog, and discovered sequence catalog. Keep the
|
||||||
|
README's simple path simple; do not duplicate complete examples or rewrite
|
||||||
|
consumer domain rules to showcase new internal types.
|
||||||
|
3. Update implementation ownership and flow for normalized requests, acquisition
|
||||||
|
policy/state/tier lifetimes, resolved context versus live handles, import file
|
||||||
|
owners, and explicit inheritance/projection/schema handoffs. Fix moved-symbol
|
||||||
|
and behavioral-test navigation and stale comments affected by the refactor.
|
||||||
|
4. Preserve public contract definitions and accepted ADR decisions. Keep deferred
|
||||||
|
clone work in `future.md`. No policy change or new ADR is needed for the
|
||||||
|
selected private refactor.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): added a consumer entry-point and ownership guide,
|
||||||
|
linked the existing adoption examples, and documented normalized requests,
|
||||||
|
acquisition/context lifetimes, import owners, and inheritance/projection/schema
|
||||||
|
handoffs. Updated test navigation and retained clone review as deferred.
|
||||||
|
Documentation links and repository boundaries passed.
|
||||||
|
|
||||||
|
**Finish:** current documentation describes implemented structure, with the
|
||||||
|
roadmap/plan retaining future work and progress until release cleanup.
|
||||||
|
|
||||||
|
## Stage 20 — Verify And Close The Combined Sprint (Complete)
|
||||||
|
|
||||||
|
1. Search for obsolete import/input forwarding ladders, synthetic `Options`
|
||||||
|
acquisition construction, `sourceVersionState.importDefaults`, discarded
|
||||||
|
context derivation branches, unused inverse bridges, and temporary scaffolds.
|
||||||
|
Remove leftovers along with exclusive test scaffolding. Preserve distinct
|
||||||
|
operations and real diagnostic conversions; do not delete private code solely
|
||||||
|
because it resembles another helper.
|
||||||
|
2. Review the combined call flow: one normalization model, non-generic acquisition,
|
||||||
|
load versus tier versus traversal state, library-owned acquisition handles
|
||||||
|
closed before composition/publication, one combination outcome for inheritance
|
||||||
|
evidence, and one state owner per reflective operation. Review for added whole-tree
|
||||||
|
traversals, policy scans, or ownership copies without requiring measurements.
|
||||||
|
3. Account for every selected finding using the table below. Confirm any new
|
||||||
|
handoff coverage addresses a concrete gap. Existing correctness/algorithmic
|
||||||
|
regressions, public-boundary tests, and meaningful assertions stay effective.
|
||||||
|
4. Run the Final validation group, resolve failures, inspect the staged diff and
|
||||||
|
workspace, and commit the completed remaining work with plain-English
|
||||||
|
messages. Report changes, checks, limitations, and any unrelated work retained.
|
||||||
|
Do not tag, push, publish, or update consumer repositories in this sprint.
|
||||||
|
|
||||||
|
Progress notes (2026-10-25): reviewed the combined request/acquisition/tier and
|
||||||
|
traversal flow, the inheritance result handoff, and the shared reflection state
|
||||||
|
owners. The named obsolete source-version field, synthetic acquisition-options
|
||||||
|
construction, and inverse conversion symbols are absent; no additional cleanup
|
||||||
|
was needed. The final development check and race-enabled full test suite passed.
|
||||||
|
|
||||||
|
| Audit finding | Completion evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| 1: imports | Stages 3–6 and 10–13 remove options reconstruction/default coupling and forwarding ladders; acquisition/scope/evidence owners and admission operations are clear. A and final checks pass. |
|
||||||
|
| 2: inheritance | Stages 14–16 return explicit template results, keep one executor, and preserve accounting and value/history/identity agreement. I and final checks pass. |
|
||||||
|
| 3: projection | Stage 17 extracts substantial handlers sharing operation state. R and final checks pass. Clone review remains deferred, not implicitly completed. |
|
||||||
|
| 4: schema | Stage 18 clarifies ordinary/inline assembly while preserving both descriptor views. R and final checks pass. |
|
||||||
|
| 5: contexts | Stages 7–9 share resolved facts across input/import/overlay/passive contexts without retaining handles or changing validation timing. A and final checks pass. |
|
||||||
|
| 6: facade | Stage 2 removes unused inverse conversions/adapters and shares real conversion. F and final boundary/consumer checks pass. |
|
||||||
|
|
||||||
|
**Finish:** the selected roadmap target is implemented, validated, documented,
|
||||||
|
and reviewable as one complete sprint. No unresolved product/scope decisions are
|
||||||
|
needed to begin these stages.
|
||||||
77
docs/roadmap/remediation.md
Normal file
77
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Consolidation Review Findings
|
||||||
|
|
||||||
|
Reviewed revision: `557e006a4ab98b666735a71b3845418c702f7cec`.
|
||||||
|
Comparison baseline: `8ab1568` (the committed implementation plan).
|
||||||
|
|
||||||
|
The review compared the [consolidation target](consolidation.md) and
|
||||||
|
[implementation plan](implementation.md) with the completed sprint, including
|
||||||
|
public/private conversions, preflight and acquisition, import selection and
|
||||||
|
evidence, filesystem contexts, inheritance, projection, schema construction,
|
||||||
|
changed tests, and documentation. The development check (`./scripts/check.sh`)
|
||||||
|
and full race suite (`go test -race ./...`) passed with Go 1.26.10 on Linux.
|
||||||
|
|
||||||
|
## 1. Complete The Non-Import Resolved-Context Handoff
|
||||||
|
|
||||||
|
Status: open. Priority: medium. Category: implementation gap and maintainability.
|
||||||
|
No user-facing correctness regression has been established for this finding.
|
||||||
|
|
||||||
|
**Target.** Roadmap internal target 2 and implementation Stages 7–9 select one
|
||||||
|
resolved filesystem description shared by validation/acquisition and passive
|
||||||
|
context construction. Stage 8 explicitly requires populating the description
|
||||||
|
at the existing non-import validation point and resolving only facts that are
|
||||||
|
still missing later.
|
||||||
|
|
||||||
|
**Evidence.** In [acquisition.go](../../internal/engine/acquisition.go),
|
||||||
|
`acquireInput` calls `validateFilesystemContext` at line 181 for inputs without
|
||||||
|
import traversal and with an applicable explicit context. That validator returns
|
||||||
|
only a diagnostic. Its host/native branches derive a resolved description but
|
||||||
|
discard it. At lines 220–226, acquisition sees `input.resolvedFilesystem == nil`
|
||||||
|
and calls `resolveFilesystemContext` again before building passive views.
|
||||||
|
|
||||||
|
For example, an ordinary `Load` of a byte or mapping source with explicit host
|
||||||
|
`FilesystemContext` and imports disabled resolves/canonicalizes the base and
|
||||||
|
root during validation, then derives those same facts again for its source
|
||||||
|
context. The retained description therefore does not complete the intended
|
||||||
|
validation-to-context handoff.
|
||||||
|
|
||||||
|
Additionally, [filesystem.go](../../internal/engine/filesystem.go) still contains
|
||||||
|
`validateAbsentHostFilesystemContext` (line 40), with its own root/base
|
||||||
|
canonicalization, root-defaulting, and containment logic. That derivation
|
||||||
|
duplicates `resolveAbsentFilesystemContext` in
|
||||||
|
[filesystem_context.go](../../internal/engine/filesystem_context.go) (line 29).
|
||||||
|
The two paths already maintain separate containment-diagnostic messages. Graph
|
||||||
|
caller inspection confirms that the old validator is reachable from
|
||||||
|
`acquireInput`; it is not unused migration scaffolding.
|
||||||
|
|
||||||
|
**Why address it.** The new model exists, but one important path still discards
|
||||||
|
and reconstructs its results, while absent-host context derivation has two
|
||||||
|
production implementations. This leaves part of audit finding 5 unresolved and
|
||||||
|
preserves the drift risk the consolidation was intended to remove. This is a
|
||||||
|
structural completion issue, not a claim of measured performance loss or a new
|
||||||
|
single-lookup guarantee.
|
||||||
|
|
||||||
|
**Required revision.**
|
||||||
|
|
||||||
|
- Have non-import context resolution/validation return or populate the resolved
|
||||||
|
description, and retain it on `assembledInput` at the existing validation
|
||||||
|
point. Later passive-view construction and ordinary-overlay handling should
|
||||||
|
reuse those applicable facts.
|
||||||
|
- Route absent-host derivation through the common resolved-context owner and
|
||||||
|
remove the duplicate derivation from the old validator. Keep the existing
|
||||||
|
directory/access checks and close-error handling at their appropriate points.
|
||||||
|
- Preserve deferred discovery/ordinary-overlay checks, root-only absent host
|
||||||
|
context, host/native distinctions, diagnostics, and borrowed filesystem
|
||||||
|
ownership. Independently required validation must not disappear merely to
|
||||||
|
reduce filesystem calls.
|
||||||
|
- Keep internal documentation aligned with the completed handoff. Describe
|
||||||
|
aggregate accounting as load-wide and repetition/namespace state as
|
||||||
|
tier-scoped; the new internal overview/import prose currently calls the latter
|
||||||
|
per-tier accounting despite its distinct role.
|
||||||
|
|
||||||
|
**Verification.** Review the non-import call path to confirm that applicable
|
||||||
|
resolved facts are carried forward and absent-host derivation has one owner.
|
||||||
|
Reuse the existing host/native, discovery selected/absent, ordinary/layered
|
||||||
|
overlay, and passive-resource-context tests. Add a focused behavioral regression
|
||||||
|
only for a consequential gap exposed by the change; do not assert incidental
|
||||||
|
canonicalization/stat call counts. Run the development check and applicable
|
||||||
|
ownership race checks after remediation.
|
||||||
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"),
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
253
internal/engine/acquisition.go
Normal file
253
internal/engine/acquisition.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
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
|
||||||
|
deferAssembly bool
|
||||||
|
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
|
||||||
|
visited := tier.visited
|
||||||
|
hostToken := tier.hostToken
|
||||||
|
role := request.role
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var versionProcessing *sourceVersionProcessingResult
|
||||||
|
if directiveContext == rootDirectiveContextSource {
|
||||||
|
var sourceID string
|
||||||
|
if parsed.source != nil {
|
||||||
|
sourceID = parsed.source.ID
|
||||||
|
}
|
||||||
|
var processed sourceVersionProcessingResult
|
||||||
|
var failures []Diagnostic
|
||||||
|
if versionState != nil {
|
||||||
|
processed, failures = versionState.processWithDirectiveContext(parsed.document, role, destination, sourceID, request.input.placementMode, directiveContext)
|
||||||
|
} else {
|
||||||
|
processed = processSourceVersionWithDirectiveContext(parsed.document, request.protectionRule, role, destination, sourceVersionModeDefault, directiveContext)
|
||||||
|
failures = processed.diagnostics
|
||||||
|
}
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return input, failures
|
||||||
|
}
|
||||||
|
versionProcessing = &processed
|
||||||
|
if role == sourceVersionMain && versionState != nil {
|
||||||
|
versionState.mainProcessing = &processed
|
||||||
|
}
|
||||||
|
} else if directiveContext == rootDirectiveContextLayered {
|
||||||
|
extracted, failures := extractRootDirectives(parsed.document, rootDirectiveContextLayered)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return input, failures
|
||||||
|
}
|
||||||
|
processed := sourceVersionProcessingResult{
|
||||||
|
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
|
||||||
|
directiveContext: rootDirectiveContextLayered,
|
||||||
|
}
|
||||||
|
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||||
|
var sourceID string
|
||||||
|
if parsed.source != nil {
|
||||||
|
sourceID = parsed.source.ID
|
||||||
|
}
|
||||||
|
versionProcessed, versionFailures := versionState.process(extracted.body, role, destination, sourceID, request.input.placementMode)
|
||||||
|
if len(versionFailures) != 0 {
|
||||||
|
return input, versionFailures
|
||||||
|
}
|
||||||
|
versionProcessed.imports = extracted.imports
|
||||||
|
versionProcessed.sequences = append(cloneSequenceDeclarations(extracted.sequences), versionProcessed.sequences...)
|
||||||
|
versionProcessed.directiveContext = rootDirectiveContextLayered
|
||||||
|
processed = versionProcessed
|
||||||
|
if role == sourceVersionMain {
|
||||||
|
versionState.mainProcessing = &processed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
versionProcessing = &processed
|
||||||
|
} else if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||||
|
var sourceID string
|
||||||
|
if parsed.source != nil {
|
||||||
|
sourceID = parsed.source.ID
|
||||||
|
}
|
||||||
|
processed, failures := versionState.process(parsed.document, role, destination, sourceID, request.input.placementMode)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return input, failures
|
||||||
|
}
|
||||||
|
versionProcessing = &processed
|
||||||
|
if role == sourceVersionMain {
|
||||||
|
versionState.mainProcessing = &processed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.sources = sourceInfos(parsed.source)
|
||||||
|
if versionProcessing != nil {
|
||||||
|
input.document = versionProcessing.payload
|
||||||
|
}
|
||||||
|
if request.input.filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||||
|
if diagnostic := validateFilesystemContext(selectedSource, request.input.filesystem, parsed); diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !request.input.imports && input.document != nil {
|
||||||
|
_, placed, err := placeDocumentWithMode(input.document, destination, request.input.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.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 versionProcessing != nil {
|
||||||
|
sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences)
|
||||||
|
}
|
||||||
|
input.acquisition = []acquisitionScope{{
|
||||||
|
owner: identity,
|
||||||
|
destination: destination.clone(),
|
||||||
|
contributions: []acquiredContribution{{
|
||||||
|
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||||
|
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||||
|
document: placed, destination: destination.clone(), processing: versionProcessing,
|
||||||
|
sequenceDeclarations: sequenceDeclarations,
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
rawNodes := countInputNodes(parsed.document)
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(destination, request.input.placementMode)
|
||||||
|
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(selectedSource, request.input.filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
accounting.sources++
|
||||||
|
accounting.bytes += parsed.bytes
|
||||||
|
accounting.nodes += rawNodes + placementNodes
|
||||||
|
}
|
||||||
|
if !activeOverlay(request.ordinaryOverlay) || request.input.imports {
|
||||||
|
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (selectedSource.kind == sourceFile || request.input.filesystem != nil || nativeContext(selectedSource, request.input.filesystem)) {
|
||||||
|
resolved, diagnostic := resolveFilesystemContext(selectedSource, request.input.filesystem, 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 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
|
||||||
|
}
|
||||||
@@ -216,9 +216,8 @@ 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,
|
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||||
)
|
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if composed != 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", composed, diagnostics)
|
||||||
@@ -279,9 +278,8 @@ 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,
|
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||||
)
|
|
||||||
if test.wantError {
|
if test.wantError {
|
||||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
if composed != 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", composed, diagnostics)
|
||||||
@@ -366,9 +364,8 @@ 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,
|
composed, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
|
||||||
)
|
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
||||||
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -412,7 +409,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 +428,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 +440,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: snapshotImportDefaults(request.importDefaults),
|
||||||
|
sequencePolicy: request.sequencePolicy,
|
||||||
|
importLimits: defaultImportLimits(),
|
||||||
|
}
|
||||||
|
return assembleLayeredInput(request, input, policy, &acquisitionState{accounting: accounting, versionState: input.versionState})
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ 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{}},
|
||||||
|
tier: &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)},
|
||||||
|
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")); len(diagnostics) != 0 {
|
||||||
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
||||||
@@ -160,11 +163,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 +179,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)
|
||||||
}
|
}
|
||||||
|
|||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,23 +3,66 @@ 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) *Diagnostic {
|
||||||
|
if source.kind == sourceNone && context.FS == nil {
|
||||||
|
return validateAbsentHostFilesystemContext(context)
|
||||||
|
}
|
||||||
if nativeContext(source, context) {
|
if nativeContext(source, context) {
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
resolved, diagnostic := resolveNativeFilesystemContext(source, context)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return diagnostic
|
return diagnostic
|
||||||
}
|
}
|
||||||
return validateNativeDirectories(access, basePath, mainPath)
|
return validateResolvedFilesystemDirectories(resolved)
|
||||||
}
|
}
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
resolved, diagnostic := resolveHostFilesystemContext(source, context, main, nil)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return diagnostic
|
return diagnostic
|
||||||
}
|
}
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(resolved.rootPath)
|
||||||
|
if err != nil {
|
||||||
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
|
value.Primary = nil
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if err := root.Close(); err != nil {
|
||||||
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic {
|
||||||
|
rootPath, err := canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return &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 &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containedPath(rootPath, basePath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "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
|
||||||
|
}
|
||||||
|
}
|
||||||
root, err := os.OpenRoot(rootPath)
|
root, err := os.OpenRoot(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{})
|
||||||
@@ -36,7 +79,16 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
|||||||
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 +121,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) {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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 := ""
|
||||||
|
|||||||
533
internal/engine/import_acquisition.go
Normal file
533
internal/engine/import_acquisition.go
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
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
|
||||||
|
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) {
|
||||||
|
main := request.main
|
||||||
|
destination := request.input.input.destination
|
||||||
|
limits := policy.importLimits
|
||||||
|
accounting := loadState.accounting
|
||||||
|
versionState := loadState.versionState
|
||||||
|
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)) {
|
||||||
|
_ = access.close()
|
||||||
|
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() {
|
||||||
|
_ = access.close()
|
||||||
|
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 {
|
||||||
|
_ = access.close()
|
||||||
|
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 {
|
||||||
|
_ = access.close()
|
||||||
|
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{})
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
state.scheduleAcquisitionScopes(&state.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeErr := access.close()
|
||||||
|
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 request.input.deferAssembly {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
acquired := &composedDocument{
|
||||||
|
acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
|
||||||
|
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
|
||||||
|
}
|
||||||
|
input := assembledInput{
|
||||||
|
parsed: main, composition: acquired, acquisition: cloneAcquisitionScopes(acquired.acquisition),
|
||||||
|
sources: state.sources, versionState: versionState, accounting: accounting,
|
||||||
|
}
|
||||||
|
composed, diagnostics := composeAcquiredInputWithPolicies(input, collectionPolicyScope{}, policy.sequencePolicy)
|
||||||
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
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) []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(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) recordEmptyImportSelection(spec importDeclarationSpec) {
|
||||||
|
for index := range s.sources {
|
||||||
|
if s.sources[index].ID != spec.location.SourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.sources[index].importSelections = append(s.sources[index].importSelections, importSelectionEvidence{
|
||||||
|
path: spec.path.clone(), location: spec.location,
|
||||||
|
selector: spec.selector, outcome: importSelectionNone,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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), 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)
|
||||||
|
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}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
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{})
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -139,72 +139,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 })
|
||||||
@@ -229,6 +180,73 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
|||||||
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 +255,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")
|
||||||
|
|||||||
@@ -251,40 +251,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 +275,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 +287,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, spec.excludeSet = values, true
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -113,10 +113,13 @@ 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]{
|
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||||
Imports: true,
|
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||||
|
directiveContext: rootDirectiveContextImportsOnly,
|
||||||
|
}, acquisitionPolicy{
|
||||||
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
|
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
|
||||||
})
|
importLimits: defaultImportLimits(),
|
||||||
|
}, nil)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assemble imports: %#v", diagnostics)
|
t.Fatalf("assemble imports: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -153,7 +156,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,7 +406,7 @@ 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,
|
||||||
}}}}},
|
}}}}},
|
||||||
@@ -617,9 +625,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, deferAssembly: true,
|
||||||
|
}, 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,7 +683,10 @@ 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})
|
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)
|
||||||
}
|
}
|
||||||
@@ -951,7 +963,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)
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1119,10 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -1268,7 +1286,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 +1297,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,7 +1332,9 @@ 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)
|
||||||
|
composed, 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)
|
||||||
@@ -1342,7 +1364,8 @@ 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)
|
||||||
|
composed, sources, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||||
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -1401,6 +1424,42 @@ 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 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)
|
||||||
|
|||||||
@@ -228,17 +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)
|
displaced := displacedOrigins(lower, origins, path)
|
||||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
replacements = append(replacements, inheritanceReplacement{
|
||||||
path: path.clone(), before: lower, after: higher,
|
path: path.clone(), before: lower, after: higher,
|
||||||
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
|
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
|
||||||
})
|
})
|
||||||
@@ -253,7 +253,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()])
|
||||||
@@ -268,7 +268,7 @@ func applyInheritanceSequenceOrigins(
|
|||||||
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
|
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],
|
path: outputPath, before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
|
||||||
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
|
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
|
||||||
})
|
})
|
||||||
@@ -278,6 +278,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 {
|
||||||
|
|||||||
127
internal/engine/inheritance_combine.go
Normal file
127
internal/engine/inheritance_combine.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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) {
|
||||||
|
previous := displacedOrigins(existing, mergedOrigins, childPath)
|
||||||
|
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||||
|
path: childPath.clone(), before: existing, after: incoming,
|
||||||
|
previous: previous, effective: cloneOrigin(child.origins[childPath.String()]),
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
)...)
|
||||||
|
history := sequenceHistoryContribution{path: childPath.clone(), mode: sequenceHistoryMapped}
|
||||||
|
if resolution.operation == sequenceModeReplace {
|
||||||
|
history.mode = sequenceHistoryReplace
|
||||||
|
if incoming != nil && incoming.Kind == document.Sequence {
|
||||||
|
history.length = len(incoming.Sequence)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
history.items = append([]sequenceItemProjection(nil), combined.higherItems...)
|
||||||
|
history.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
|
||||||
|
}
|
||||||
|
sequenceContributions = append(sequenceContributions, history)
|
||||||
|
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
|
||||||
|
}
|
||||||
88
internal/engine/inheritance_prepare.go
Normal file
88
internal/engine/inheritance_prepare.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
type preparedInheritanceChild struct {
|
||||||
|
payload *document.Node
|
||||||
|
origins map[string]Origin
|
||||||
|
policyTemplates collectionPolicyScope
|
||||||
|
control *inheritanceControl
|
||||||
|
controlOrigin Origin
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparedInheritanceParent struct {
|
||||||
|
payload *document.Node
|
||||||
|
historyParent *document.Node
|
||||||
|
policyTemplates []collectionPolicyScope
|
||||||
|
effectivePolicyTemplates []collectionPolicyScope
|
||||||
|
parentPolicyScope collectionPolicyScope
|
||||||
|
childPolicyScope collectionPolicyScope
|
||||||
|
replaced collectionReplacementPaths
|
||||||
|
transfer inheritanceTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareInheritanceChild(template inheritanceExecutionTemplate, inputOrigins map[string]Origin) preparedInheritanceChild {
|
||||||
|
policyTemplates := cloneCollectionPolicyScopeForInheritance(template.collectionPolicies)
|
||||||
|
payload := withoutInheritanceDirective(template.entry.Value)
|
||||||
|
prepared := preparedInheritanceChild{
|
||||||
|
payload: payload, origins: directInheritanceOrigins(payload, template.path, inputOrigins),
|
||||||
|
policyTemplates: policyTemplates,
|
||||||
|
}
|
||||||
|
if template.hasDirective {
|
||||||
|
control := inheritanceControl{
|
||||||
|
path: template.directivePath, locations: []Location{publicLocation(template.directive.Value.Location)},
|
||||||
|
cancelled: template.cancelled,
|
||||||
|
}
|
||||||
|
controlOrigins := directInheritanceOrigins(template.directive.Value, control.path, inputOrigins)
|
||||||
|
prepared.control = &control
|
||||||
|
prepared.controlOrigin = controlOrigins[control.path.String()]
|
||||||
|
}
|
||||||
|
return prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareInheritanceParent(
|
||||||
|
template inheritanceExecutionTemplate,
|
||||||
|
parentPath Path,
|
||||||
|
parent *document.Node,
|
||||||
|
resolvedParentPolicyTemplates []collectionPolicyScope,
|
||||||
|
resolvedParentPolicies collectionPolicyScope,
|
||||||
|
child preparedInheritanceChild,
|
||||||
|
applicationPolicy sequencePolicy,
|
||||||
|
) preparedInheritanceParent {
|
||||||
|
parentPolicies := cloneInheritancePolicyTemplates(resolvedParentPolicyTemplates)
|
||||||
|
effectivePolicies := append(
|
||||||
|
cloneInheritancePolicyTemplates(parentPolicies),
|
||||||
|
cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
|
||||||
|
)
|
||||||
|
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedParentPolicies, template.path)
|
||||||
|
childPolicyScope := projectCurrentInheritanceCollectionPolicies(child.policyTemplates, template.path)
|
||||||
|
var replaced collectionReplacementPaths
|
||||||
|
if len(parentPolicyScope.collections) != 0 || len(childPolicyScope.collections) != 0 {
|
||||||
|
claims := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||||
|
claims.append(child.payload, template.path, 0)
|
||||||
|
declarations := make(map[string][]sequenceDeclarationSpec, len(childPolicyScope.collections))
|
||||||
|
for key, entry := range childPolicyScope.collections {
|
||||||
|
declarations[key] = inheritanceDeclarationsAt(entry.destination, entry.policy)
|
||||||
|
}
|
||||||
|
parentPolicyScope, replaced = lowerCollectionPoliciesForClaims(parentPolicyScope, claims.ordered, declarations, applicationPolicy)
|
||||||
|
}
|
||||||
|
parentPayload := inheritanceParentPayload(parent, template.identityFields)
|
||||||
|
historyParent := parent
|
||||||
|
if len(template.identityFields) != 0 {
|
||||||
|
// The retained parent snapshot is the inherited payload, before child
|
||||||
|
// fields are applied, with parent identity excluded.
|
||||||
|
historyParent = document.Clone(parentPayload)
|
||||||
|
}
|
||||||
|
transfer := inheritanceTransfer{
|
||||||
|
from: parentPath, to: template.path, reference: template.directivePath,
|
||||||
|
locations: []Location{publicLocation(template.directive.Value.Location)},
|
||||||
|
policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
|
||||||
|
projectedPolicies: projectInheritancePolicyTemplates([]collectionPolicyScope{resolvedParentPolicies}, template.path),
|
||||||
|
}
|
||||||
|
return preparedInheritanceParent{
|
||||||
|
payload: parentPayload, historyParent: historyParent,
|
||||||
|
policyTemplates: parentPolicies, effectivePolicyTemplates: effectivePolicies,
|
||||||
|
parentPolicyScope: parentPolicyScope, childPolicyScope: childPolicyScope,
|
||||||
|
replaced: replaced, transfer: transfer,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,193 +188,52 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
|
|||||||
|
|
||||||
for _, index := range order {
|
for _, index := range order {
|
||||||
node := templates[index]
|
node := templates[index]
|
||||||
templatePolicies := cloneCollectionPolicyScopeForInheritance(node.collectionPolicies)
|
child := prepareInheritanceChild(node, inputOrigins)
|
||||||
payload := withoutInheritanceDirective(node.entry.Value)
|
|
||||||
operationStart, replacementStart := len(result.operations), len(result.replacements)
|
|
||||||
childOrigins := directInheritanceOrigins(payload, node.path, inputOrigins)
|
|
||||||
if node.hasDirective {
|
|
||||||
control := inheritanceControl{
|
|
||||||
path: node.directivePath,
|
|
||||||
locations: []Location{publicLocation(node.directive.Value.Location)},
|
|
||||||
cancelled: node.cancelled,
|
|
||||||
}
|
|
||||||
result.controls = append(result.controls, control)
|
|
||||||
controlOrigins := directInheritanceOrigins(node.directive.Value, control.path, inputOrigins)
|
|
||||||
result.operations = append(result.operations, inheritanceOperation{
|
|
||||||
control: &control, controlOrigin: controlOrigins[control.path.String()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if limits.nodes >= 0 {
|
if limits.nodes >= 0 {
|
||||||
remaining := limits.nodes - retained - adapter.slotOverhead()
|
remaining := limits.nodes - retained - adapter.slotOverhead()
|
||||||
within := inheritanceLowerBoundWithin(payload, remaining)
|
within := inheritanceLowerBoundWithin(child.payload, remaining)
|
||||||
if node.hasParent {
|
if node.hasParent {
|
||||||
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], payload, remaining)
|
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], child.payload, remaining)
|
||||||
}
|
}
|
||||||
if !within {
|
if !within {
|
||||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parent *preparedInheritanceParent
|
||||||
|
var parentOrigins map[string]Origin
|
||||||
if node.hasParent {
|
if node.hasParent {
|
||||||
parent := resolved[node.parent]
|
prepared := prepareInheritanceParent(
|
||||||
parentPolicies := cloneInheritancePolicyTemplates(resolvedPolicyTemplates[node.parent])
|
node, templates[node.parent].path, resolved[node.parent], resolvedPolicyTemplates[node.parent],
|
||||||
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
|
resolvedPolicies[node.parent], child, adapter.applicationPolicy(),
|
||||||
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
|
|
||||||
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
|
|
||||||
var replaced collectionReplacementPaths
|
|
||||||
if len(parentPolicyScope.collections) != 0 || len(childPolicyScope.collections) != 0 {
|
|
||||||
claims := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
|
||||||
claims.append(payload, node.path, 0)
|
|
||||||
declarations := make(map[string][]sequenceDeclarationSpec, len(childPolicyScope.collections))
|
|
||||||
for key, entry := range childPolicyScope.collections {
|
|
||||||
declarations[key] = inheritanceDeclarationsAt(entry.destination, entry.policy)
|
|
||||||
}
|
|
||||||
parentPolicyScope, replaced = lowerCollectionPoliciesForClaims(parentPolicyScope, claims.ordered, declarations, adapter.applicationPolicy())
|
|
||||||
}
|
|
||||||
merged := inheritanceParentPayload(parent, node.identityFields)
|
|
||||||
historyParent := parent
|
|
||||||
if len(node.identityFields) != 0 {
|
|
||||||
// The retained parent snapshot is the inherited payload, before
|
|
||||||
// child fields are applied, with parent identity excluded.
|
|
||||||
historyParent = document.Clone(merged)
|
|
||||||
}
|
|
||||||
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
|
|
||||||
parentPolicyScope, childPolicyScope, node.path, parent, payload, adapter.applicationPolicy(), replaced,
|
|
||||||
)
|
)
|
||||||
if len(policyDiagnostics) != 0 {
|
parent = &prepared
|
||||||
return nil, policyDiagnostics
|
parentOrigins = resolvedOrigins[node.parent]
|
||||||
}
|
|
||||||
transfer := inheritanceTransfer{
|
|
||||||
from: templates[node.parent].path, to: node.path,
|
|
||||||
reference: node.directivePath,
|
|
||||||
locations: []Location{publicLocation(node.directive.Value.Location)},
|
|
||||||
policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
|
|
||||||
projectedPolicies: projectInheritancePolicyTemplates(
|
|
||||||
[]collectionPolicyScope{resolvedPolicies[node.parent]}, node.path,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[node.parent], transfer.from, node.path, transfer)
|
|
||||||
mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()])
|
|
||||||
var sequenceContributions []sequenceHistoryContribution
|
|
||||||
var combinationDiagnostics []Diagnostic
|
|
||||||
mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{
|
|
||||||
added: func(childPath Path, incoming *document.Node) {
|
|
||||||
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
|
||||||
},
|
|
||||||
combined: func(childPath Path, _ *document.Node) {
|
|
||||||
mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()])
|
|
||||||
},
|
|
||||||
replaced: func(childPath Path, existing, incoming *document.Node) {
|
|
||||||
previous := displacedOrigins(existing, mergedOrigins, childPath)
|
|
||||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
|
||||||
path: childPath.clone(), before: existing, after: incoming,
|
|
||||||
previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]),
|
|
||||||
})
|
|
||||||
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
|
||||||
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
|
||||||
},
|
|
||||||
merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) {
|
|
||||||
resolution, _, _, active, issues := inheritanceCollectionResolution(
|
|
||||||
childPath, parentPolicyScope, childPolicyScope, adapter.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, parentPolicyScope, childPolicyScope,
|
|
||||||
)
|
|
||||||
mergedPolicyScope.set(childPath, policy)
|
|
||||||
applyInheritanceSequenceOrigins(
|
|
||||||
result, mergedOrigins, childOrigins, childPath, lowerValue, incoming, combined, resolution.operation,
|
|
||||||
)
|
|
||||||
history := sequenceHistoryContribution{path: childPath.clone(), mode: sequenceHistoryMapped}
|
|
||||||
if resolution.operation == sequenceModeReplace {
|
|
||||||
history.mode = sequenceHistoryReplace
|
|
||||||
if incoming != nil && incoming.Kind == document.Sequence {
|
|
||||||
history.length = len(incoming.Sequence)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
history.items = append([]sequenceItemProjection(nil), combined.higherItems...)
|
|
||||||
history.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
|
|
||||||
}
|
|
||||||
sequenceContributions = append(sequenceContributions, history)
|
|
||||||
return combined.value, true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
preserveInheritanceIdentityFields(merged, payload, node.identityFields)
|
|
||||||
if len(combinationDiagnostics) != 0 {
|
|
||||||
return nil, canonicalDiagnostics(combinationDiagnostics)
|
|
||||||
}
|
|
||||||
if problems := validateRegisteredInheritanceSequences(merged, node.path, adapter.applicationPolicy()); len(problems) != 0 {
|
|
||||||
return nil, problems
|
|
||||||
}
|
|
||||||
merged.Location = node.entry.Value.Location
|
|
||||||
resolved[index], resolvedOrigins[index] = merged, mergedOrigins
|
|
||||||
resolvedPolicies[index] = inheritancePolicyScopeAtPath(mergedPolicyScope, node.path)
|
|
||||||
result.transfers = append(result.transfers, transfer)
|
|
||||||
result.operations = append(result.operations, inheritanceOperation{
|
|
||||||
transfer: &transfer, parent: historyParent, effective: merged,
|
|
||||||
parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins,
|
|
||||||
parentPolicies: cloneInheritancePolicyTemplates(parentPolicies),
|
|
||||||
templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
|
|
||||||
effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies),
|
|
||||||
sequences: cloneSequenceHistoryContributions(sequenceContributions),
|
|
||||||
identityFields: cloneStrings(node.identityFields),
|
|
||||||
})
|
|
||||||
resolvedPolicyTemplates[index] = effectivePolicies
|
|
||||||
} else {
|
|
||||||
resolved[index] = document.Clone(payload)
|
|
||||||
resolvedOrigins[index] = childOrigins
|
|
||||||
resolvedPolicyTemplates[index] = []collectionPolicyScope{templatePolicies}
|
|
||||||
resolvedPolicies[index] = cloneCollectionPolicyScopeForInheritance(templatePolicies)
|
|
||||||
}
|
}
|
||||||
|
templateResult, diagnostics := combineInheritanceTemplate(
|
||||||
|
node, child, parent, parentOrigins, adapter.applicationPolicy(),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
resolved[index] = templateResult.value
|
||||||
|
resolvedOrigins[index] = templateResult.origins
|
||||||
|
resolvedPolicies[index] = templateResult.collectionPolicies
|
||||||
|
resolvedPolicyTemplates[index] = templateResult.policyTemplates
|
||||||
|
result.controls = append(result.controls, templateResult.controls...)
|
||||||
|
result.transfers = append(result.transfers, templateResult.transfers...)
|
||||||
|
result.replacements = append(result.replacements, templateResult.replacements...)
|
||||||
|
result.operations = append(result.operations, templateResult.operations...)
|
||||||
|
|
||||||
count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes)
|
count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes)
|
||||||
retained += adapter.slotOverhead() + count
|
retained += adapter.slotOverhead() + count
|
||||||
for _, operation := range result.operations[operationStart:] {
|
if diagnostic := chargeInheritanceTemplateHistory(counter, templateResult, node); diagnostic != nil {
|
||||||
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
return nil, []Diagnostic{*diagnostic}
|
||||||
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, replacement := range result.replacements[replacementStart:] {
|
|
||||||
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
|
||||||
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
root := adapter.rebuild(resolved)
|
if diagnostics := finalizeInheritanceScope(adapter, result, templates, resolved, resolvedOrigins, resolvedPolicies, inputOrigins, limits); len(diagnostics) != 0 {
|
||||||
result.collectionPolicies = collectionPolicyScope{}
|
return nil, diagnostics
|
||||||
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 nil, []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)}
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestPendingItemCompositionProjectsNestedIdentityDiagnostics(t *testing.T) {
|
|||||||
destination: Keys("items"), pendingItem: &itemScope,
|
destination: Keys("items"), pendingItem: &itemScope,
|
||||||
}}}
|
}}}
|
||||||
|
|
||||||
traversal := importTraversal{accounting: &importAccounting{}, limits: defaultImportLimits()}
|
traversal := compositionTraversal(sequencePolicy{}, nil, nil, &importAccounting{}, newImportInclusionGraph())
|
||||||
diagnostics := traversal.composePendingItems(&outer)
|
diagnostics := traversal.composePendingItems(&outer)
|
||||||
if len(diagnostics) != 1 {
|
if len(diagnostics) != 1 {
|
||||||
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
|
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
|
||||||
|
|||||||
@@ -21,25 +21,25 @@ type Definition[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
// Imports enables traversal of declarations in a selected main document; an
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// absent source has no main declarations to traverse. Overlay selects one fixed
|
||||||
|
// overlay and requires filesystem context for NoSource. Overrides contain only
|
||||||
|
// values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
Overlay string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
SourceVersionMode sourceVersionMode
|
SourceVersionMode sourceVersionMode
|
||||||
importDefaults importDefaults
|
importDefaults importDefaults
|
||||||
destination Path
|
destination Path
|
||||||
placementMode placementMode
|
placementMode placementMode
|
||||||
sequencePolicy sequencePolicy
|
|
||||||
deferAssembly bool
|
|
||||||
directiveContext rootDirectiveContext
|
|
||||||
importDefaultsValidated bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
// FilesystemContext supplies filesystem roots for imports and overlays. For an
|
||||||
|
// absent host input, Root is required and BaseDir may be omitted to use Root as
|
||||||
|
// the import base. Native contexts still require both BaseDir and Root.
|
||||||
type FilesystemContext struct {
|
type FilesystemContext struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
Root string
|
Root string
|
||||||
@@ -149,20 +149,31 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
if len(supplied) > 1 {
|
||||||
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
|
return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}}).loadError()
|
||||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
}
|
||||||
|
var options Options[T]
|
||||||
|
if len(supplied) == 1 {
|
||||||
|
options = supplied[0]
|
||||||
|
}
|
||||||
|
input, diagnostics := normalizeInput(loadInput{
|
||||||
|
source: source, imports: options.Imports, filesystem: options.Filesystem,
|
||||||
|
destination: options.destination.clone(), placementMode: options.placementMode,
|
||||||
|
}, options.Overlay)
|
||||||
|
defaults, policy, settingsDiagnostics := loadSettings(options.importDefaults, options.Definition, options.SourceVersionMode)
|
||||||
|
diagnostics = append(diagnostics, settingsDiagnostics...)
|
||||||
|
diagnostics = append(diagnostics, overlayOptionDiagnostics(options.Overlay)...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
||||||
}
|
}
|
||||||
definition := options.Definition
|
definition := options.Definition
|
||||||
definition.sequenceIdentityRegistrations = nil
|
definition.sequenceIdentityRegistrations = nil
|
||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
main: input,
|
||||||
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,
|
definition: definition, importDefaults: defaults, sequencePolicy: policy, overlay: options.Overlay,
|
||||||
overrides: options.Overrides, ordinary: true,
|
overrides: options.Overrides, ordinary: true,
|
||||||
sourceVersionMode: options.SourceVersionMode,
|
sourceVersionMode: options.SourceVersionMode,
|
||||||
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
|
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, input.destination, options.SourceVersionMode, input.placementMode),
|
||||||
}
|
}
|
||||||
return executeLoadRequest(request, secrets, lookup)
|
return executeLoadRequest(request, secrets, lookup)
|
||||||
}
|
}
|
||||||
@@ -170,17 +181,10 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
|
|||||||
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
|
policy := acquisitionPolicy{
|
||||||
mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults)
|
importDefaults: snapshotImportDefaults(request.importDefaults),
|
||||||
mainOptions.sequencePolicy = request.sequencePolicy
|
sequencePolicy: request.sequencePolicy,
|
||||||
mainOptions.deferAssembly = true
|
importLimits: defaultImportLimits(),
|
||||||
mainOptions.SourceVersionMode = request.sourceVersionMode
|
|
||||||
mainOptions.Definition.Version = request.protectionVersion()
|
|
||||||
if request.ordinary {
|
|
||||||
mainOptions.Overlay = request.overlay
|
|
||||||
mainOptions.directiveContext = rootDirectiveContextSource
|
|
||||||
} else {
|
|
||||||
mainOptions.directiveContext = rootDirectiveContextLayered
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var schema *schemaDescriptor
|
var schema *schemaDescriptor
|
||||||
@@ -194,10 +198,21 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
versionState := &sourceVersionState{
|
versionState := &sourceVersionState{
|
||||||
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
||||||
importDefaults: snapshotImportDefaults(request.importDefaults),
|
|
||||||
}
|
}
|
||||||
|
state := &acquisitionState{accounting: accounting, versionState: versionState}
|
||||||
accumulator.versionState = versionState
|
accumulator.versionState = versionState
|
||||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
directiveContext := rootDirectiveContextLayered
|
||||||
|
ordinaryOverlay := ""
|
||||||
|
if request.ordinary {
|
||||||
|
directiveContext = rootDirectiveContextSource
|
||||||
|
ordinaryOverlay = request.overlay
|
||||||
|
}
|
||||||
|
input, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||||
|
input: request.main, role: sourceVersionMain, directiveContext: directiveContext,
|
||||||
|
protectionRule: request.protectionVersion(), deferAssembly: true,
|
||||||
|
ordinaryOverlay: ordinaryOverlay,
|
||||||
|
}, policy, state, &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)})
|
||||||
|
request.main.source = input.source
|
||||||
accumulator.sources = input.sources
|
accumulator.sources = input.sources
|
||||||
accumulator.add(versionState.diagnostics...)
|
accumulator.add(versionState.diagnostics...)
|
||||||
reportedVersionDiagnostics := len(versionState.diagnostics)
|
reportedVersionDiagnostics := len(versionState.diagnostics)
|
||||||
@@ -214,7 +229,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if request.ordinary && (input.document != nil || input.composition != nil) {
|
if request.ordinary && (input.document != nil || input.composition != nil) {
|
||||||
input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy)
|
input.composition, diagnostics = compositionForInputWithPolicy(input, policy.sequencePolicy)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
@@ -230,10 +245,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
if request.ordinary {
|
if request.ordinary {
|
||||||
versionState.locateMainVersion(input.document)
|
versionState.locateMainVersion(input.document)
|
||||||
mainOptions.Definition.Version = versionState.effectiveRule
|
input, diagnostics = applyInputLayers(request.main.source, request.main.filesystem, request.overlay, versionState.effectiveRule, policy.sequencePolicy, input)
|
||||||
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
|
|
||||||
} else {
|
} else {
|
||||||
input, diagnostics = assembleLayeredInput(request, input, accounting)
|
input, diagnostics = assembleLayeredInput(request, input, policy, state)
|
||||||
}
|
}
|
||||||
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
|
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
|
||||||
accumulator.sources = input.sources
|
accumulator.sources = input.sources
|
||||||
@@ -250,7 +264,15 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(baseSources) != 0 && input.contexts[baseSources[0].ID].FS == nil {
|
if len(baseSources) != 0 && input.contexts[baseSources[0].ID].FS == nil {
|
||||||
contexts, diagnostic := contextsForAcquiredSources(request.main.source, request.main.filesystem, input.parsed.source, baseSources)
|
if input.resolvedFilesystem == nil && (request.main.source.kind == sourceFile || request.main.filesystem != nil || nativeContext(request.main.source, request.main.filesystem)) {
|
||||||
|
resolved, diagnostic := resolveFilesystemContext(request.main.source, request.main.filesystem, input.parsed, nil)
|
||||||
|
if diagnostic != nil {
|
||||||
|
accumulator.add(*diagnostic)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
input.resolvedFilesystem = &resolved
|
||||||
|
}
|
||||||
|
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, baseSources)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
accumulator.add(*diagnostic)
|
accumulator.add(*diagnostic)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
@@ -265,7 +287,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
expanded, problems := expandInheritanceWithCollectionRules(
|
expanded, problems := expandInheritanceWithCollectionRules(
|
||||||
composition.root, composition.origins, composition.collectionPolicies, request.sequencePolicy, defaultProjectionLimits(),
|
composition.root, composition.origins, composition.collectionPolicies, policy.sequencePolicy, defaultProjectionLimits(),
|
||||||
)
|
)
|
||||||
if len(problems) != 0 {
|
if len(problems) != 0 {
|
||||||
accumulator.add(problems...)
|
accumulator.add(problems...)
|
||||||
@@ -337,7 +359,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
if hasErrors(secretResolution.diagnostics) {
|
if hasErrors(secretResolution.diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, request.sequencePolicy)
|
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, policy.sequencePolicy)
|
||||||
accumulator.add(identityDiagnostics...)
|
accumulator.add(identityDiagnostics...)
|
||||||
if hasErrors(identityDiagnostics) {
|
if hasErrors(identityDiagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assembledInput is the source/document handoff across schema and version checks.
|
// assembledInput is the source/document handoff across schema and version checks.
|
||||||
type assembledInput struct {
|
type assembledInput struct {
|
||||||
parsed parsedSource
|
source Source
|
||||||
document *document.Node
|
parsed parsedSource
|
||||||
composition *composedDocument
|
document *document.Node
|
||||||
acquisition []acquisitionScope
|
composition *composedDocument
|
||||||
inheritance *inheritanceScope
|
acquisition []acquisitionScope
|
||||||
sources []SourceInfo
|
inheritance *inheritanceScope
|
||||||
contexts map[string]ResourceContext
|
sources []SourceInfo
|
||||||
versionState *sourceVersionState
|
contexts map[string]ResourceContext
|
||||||
accounting *importAccounting
|
versionState *sourceVersionState
|
||||||
|
accounting *importAccounting
|
||||||
|
resolvedFilesystem *resolvedFilesystemContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquiredContribution keeps a source-local payload beside its placed document.
|
// acquiredContribution keeps a source-local payload beside its placed document.
|
||||||
@@ -88,227 +88,42 @@ func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
func applyInputLayers(source Source, filesystem *FilesystemContext, overlay string, protectionRule *VersionRule, policy sequencePolicy, input assembledInput) (assembledInput, []Diagnostic) {
|
||||||
return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil)
|
if !activeOverlay(overlay) {
|
||||||
}
|
|
||||||
|
|
||||||
func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
|
||||||
return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (assembledInput, []Diagnostic) {
|
|
||||||
return assembleBaseInputTrackedForRole(source, options, destination, accounting, visited, hostToken, versionState, sourceVersionMain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
|
||||||
limits := defaultImportLimits()
|
|
||||||
parseLimits := defaultParseLimits()
|
|
||||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
|
||||||
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
|
||||||
if parsed.source != nil {
|
|
||||||
parsed.source.Destination = destination.clone()
|
|
||||||
parsed.source.placementMode = options.placementMode
|
|
||||||
}
|
|
||||||
input := assembledInput{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 source.kind == sourceMapping && source.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 := options.directiveContext
|
|
||||||
if directiveContext == 0 {
|
|
||||||
directiveContext = rootDirectiveContextImportsOnly
|
|
||||||
}
|
|
||||||
if !options.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var versionProcessing *sourceVersionProcessingResult
|
|
||||||
if directiveContext == rootDirectiveContextSource {
|
|
||||||
var sourceID string
|
|
||||||
if parsed.source != nil {
|
|
||||||
sourceID = parsed.source.ID
|
|
||||||
}
|
|
||||||
var processed sourceVersionProcessingResult
|
|
||||||
var failures []Diagnostic
|
|
||||||
if versionState != nil {
|
|
||||||
processed, failures = versionState.processWithDirectiveContext(parsed.document, role, destination, sourceID, options.placementMode, directiveContext)
|
|
||||||
} else {
|
|
||||||
processed = processSourceVersionWithDirectiveContext(parsed.document, options.Definition.Version, role, destination, sourceVersionModeDefault, directiveContext)
|
|
||||||
failures = processed.diagnostics
|
|
||||||
}
|
|
||||||
if len(failures) != 0 {
|
|
||||||
return input, failures
|
|
||||||
}
|
|
||||||
versionProcessing = &processed
|
|
||||||
if role == sourceVersionMain && versionState != nil {
|
|
||||||
versionState.mainProcessing = &processed
|
|
||||||
}
|
|
||||||
} else if directiveContext == rootDirectiveContextLayered {
|
|
||||||
extracted, failures := extractRootDirectives(parsed.document, rootDirectiveContextLayered)
|
|
||||||
if len(failures) != 0 {
|
|
||||||
return input, failures
|
|
||||||
}
|
|
||||||
processed := sourceVersionProcessingResult{
|
|
||||||
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
|
|
||||||
directiveContext: rootDirectiveContextLayered,
|
|
||||||
}
|
|
||||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
|
||||||
var sourceID string
|
|
||||||
if parsed.source != nil {
|
|
||||||
sourceID = parsed.source.ID
|
|
||||||
}
|
|
||||||
versionProcessed, versionFailures := versionState.process(extracted.body, role, destination, sourceID, options.placementMode)
|
|
||||||
if len(versionFailures) != 0 {
|
|
||||||
return input, versionFailures
|
|
||||||
}
|
|
||||||
versionProcessed.imports = extracted.imports
|
|
||||||
versionProcessed.sequences = append(cloneSequenceDeclarations(extracted.sequences), versionProcessed.sequences...)
|
|
||||||
versionProcessed.directiveContext = rootDirectiveContextLayered
|
|
||||||
processed = versionProcessed
|
|
||||||
if role == sourceVersionMain {
|
|
||||||
versionState.mainProcessing = &processed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
versionProcessing = &processed
|
|
||||||
} else if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
|
||||||
var sourceID string
|
|
||||||
if parsed.source != nil {
|
|
||||||
sourceID = parsed.source.ID
|
|
||||||
}
|
|
||||||
processed, failures := versionState.process(parsed.document, role, destination, sourceID, options.placementMode)
|
|
||||||
if len(failures) != 0 {
|
|
||||||
return input, failures
|
|
||||||
}
|
|
||||||
versionProcessing = &processed
|
|
||||||
if role == sourceVersionMain {
|
|
||||||
versionState.mainProcessing = &processed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if options.Imports {
|
|
||||||
if options.deferAssembly {
|
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
|
||||||
} else {
|
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
|
||||||
}
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return input, diagnostics
|
|
||||||
}
|
|
||||||
if input.composition != nil {
|
|
||||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
|
||||||
input.document = input.composition.root
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
input.sources = sourceInfos(parsed.source)
|
|
||||||
if versionProcessing != nil {
|
|
||||||
input.document = versionProcessing.payload
|
|
||||||
}
|
|
||||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
|
||||||
return input, []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !options.Imports && input.document != nil {
|
|
||||||
_, placed, err := placeDocumentWithMode(input.document, destination, options.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.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 versionProcessing != nil {
|
|
||||||
sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences)
|
|
||||||
}
|
|
||||||
input.acquisition = []acquisitionScope{{
|
|
||||||
owner: identity,
|
|
||||||
destination: destination.clone(),
|
|
||||||
contributions: []acquiredContribution{{
|
|
||||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
|
||||||
identity: identity, role: importRoleMain, scopeOwner: identity,
|
|
||||||
document: placed, destination: destination.clone(), processing: versionProcessing,
|
|
||||||
sequenceDeclarations: sequenceDeclarations,
|
|
||||||
}},
|
|
||||||
}}
|
|
||||||
rawNodes := countInputNodes(parsed.document)
|
|
||||||
placementNodes := destinationWrapperNodeCountForMode(destination, options.placementMode)
|
|
||||||
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(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
|
||||||
return input, []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
accounting.sources++
|
|
||||||
accounting.bytes += parsed.bytes
|
|
||||||
accounting.nodes += rawNodes + placementNodes
|
|
||||||
}
|
|
||||||
if !activeOverlay(options.Overlay) || options.Imports {
|
|
||||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return input, []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
input.contexts = contexts
|
|
||||||
}
|
|
||||||
return input, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
|
||||||
if !activeOverlay(options.Overlay) {
|
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
|
composition, diagnostics := compositionForInputWithPolicy(input, policy)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
assembly := newAssemblyAccumulator(composition)
|
assembly := newAssemblyAccumulator(composition)
|
||||||
if activeOverlay(options.Overlay) {
|
resolved, diagnostic := resolveOrdinaryOverlayFilesystem(source, filesystem, input.parsed, input.resolvedFilesystem)
|
||||||
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
|
if diagnostic != nil {
|
||||||
source, options.Filesystem, input.parsed, assembly, input.sources,
|
return input, []Diagnostic{*diagnostic}
|
||||||
options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState, options.sequencePolicy,
|
}
|
||||||
)
|
if source.kind != sourceNone || nativeContext(source, filesystem) {
|
||||||
if len(diagnostics) != 0 {
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
return input, diagnostics
|
|
||||||
}
|
|
||||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, input.parsed.source, input.sources[len(input.sources)-1:])
|
|
||||||
if diagnostic != nil {
|
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
mergeResourceContexts(&input.contexts, contexts)
|
|
||||||
}
|
}
|
||||||
|
request := selectedOverlayRequest{
|
||||||
|
name: overlay, filesystem: resolved, protectionRule: protectionRule,
|
||||||
|
sequencePolicy: policy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||||
|
}
|
||||||
|
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
input.sources, diagnostics = loadSelectedOverlay(request, access, assembly, input.sources)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
input.resolvedFilesystem = &resolved
|
||||||
|
contexts, diagnostic := contextsForResolvedSources(&resolved, input.sources[len(input.sources)-1:])
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
mergeResourceContexts(&input.contexts, contexts)
|
||||||
input.composition = assembly.current
|
input.composition = assembly.current
|
||||||
input.document = assembly.current.root
|
input.document = assembly.current.root
|
||||||
return input, nil
|
return input, nil
|
||||||
|
|||||||
@@ -49,35 +49,23 @@ type acquiredUnderlayGroup struct {
|
|||||||
|
|
||||||
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
||||||
request.ordinary = false
|
request.ordinary = false
|
||||||
var diagnostics []Diagnostic
|
defaults, policy, diagnostics := loadSettings(request.importDefaults, request.definition, request.sourceVersionMode)
|
||||||
request.importDefaults = snapshotImportDefaults(request.importDefaults)
|
request.importDefaults = defaults
|
||||||
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
|
|
||||||
policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations)
|
|
||||||
request.sequencePolicy = policy
|
request.sequencePolicy = policy
|
||||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
|
||||||
request.definition.sequenceIdentityRegistrations = nil
|
request.definition.sequenceIdentityRegistrations = nil
|
||||||
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
main, mainDiagnostics := normalizeInput(request.main, "")
|
||||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
request.main = main
|
||||||
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
|
||||||
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
|
||||||
}})
|
|
||||||
request.main.destination = mainOptions.destination
|
|
||||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||||
request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
|
request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
|
||||||
for groupIndex := range request.underlays {
|
for groupIndex := range request.underlays {
|
||||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||||
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{
|
normalized, inputDiagnostics := normalizeInput(*input, "")
|
||||||
Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode,
|
*input = normalized
|
||||||
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
|
||||||
}})
|
|
||||||
input.destination = inputOptions.destination
|
|
||||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !validOverlayName(request.overlay) {
|
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
|
||||||
}
|
|
||||||
if activeOverlay(request.overlay) {
|
if activeOverlay(request.overlay) {
|
||||||
if request.overlayFilesystem == nil {
|
if request.overlayFilesystem == nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
||||||
@@ -91,14 +79,14 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
|||||||
return executeLoadRequest(request, secrets, lookup)
|
return executeLoadRequest(request, secrets, lookup)
|
||||||
}
|
}
|
||||||
|
|
||||||
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, policy acquisitionPolicy, state *acquisitionState) (assembledInput, []Diagnostic) {
|
||||||
var assembly *assemblyAccumulator
|
var assembly *assemblyAccumulator
|
||||||
mainSourceCount := len(input.sources)
|
mainSourceCount := len(input.sources)
|
||||||
mainEvidence := input.versionState.evidenceSince(0)
|
mainEvidence := input.versionState.evidenceSince(0)
|
||||||
projectVersion := sequenceVersionPath(request.protectionVersion())
|
projectVersion := sequenceVersionPath(request.protectionVersion())
|
||||||
for index, group := range request.underlays {
|
for index, group := range request.underlays {
|
||||||
evidenceStart := len(input.versionState.evidenceSince(0))
|
evidenceStart := len(input.versionState.evidenceSince(0))
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), accounting, input.versionState, request.sequencePolicy)
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy)
|
||||||
mergeResourceContexts(&input.contexts, acquired.contexts)
|
mergeResourceContexts(&input.contexts, acquired.contexts)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
input.sources = append(input.sources, acquired.sources...)
|
input.sources = append(input.sources, acquired.sources...)
|
||||||
@@ -114,7 +102,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
// Source-local checks still run during acquisition above.
|
// Source-local checks still run during acquisition above.
|
||||||
protectionRule = nil
|
protectionRule = nil
|
||||||
}
|
}
|
||||||
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, request.sequencePolicy, lower)
|
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, policy.sequencePolicy, lower)
|
||||||
sourceStart := len(input.sources)
|
sourceStart := len(input.sources)
|
||||||
input.sources = append(input.sources, acquired.sources...)
|
input.sources = append(input.sources, acquired.sources...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -130,7 +118,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
}
|
}
|
||||||
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||||
composition.root, composition.origins, composition.collectionPolicies, composition.applications, LayerUnderlay,
|
composition.root, composition.origins, composition.collectionPolicies, composition.applications, LayerUnderlay,
|
||||||
request.sequencePolicy, declarations,
|
policy.sequencePolicy, declarations,
|
||||||
)
|
)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
@@ -148,7 +136,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
lower = assembly.current.collectionPolicies
|
lower = assembly.current.collectionPolicies
|
||||||
}
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
mainComposition, diagnostics = compositionForInputWithPolicies(input, lower, request.sequencePolicy)
|
mainComposition, diagnostics = compositionForInputWithPolicies(input, lower, policy.sequencePolicy)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
@@ -177,7 +165,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
}
|
}
|
||||||
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||||
mainComposition.root, mainComposition.origins, mainComposition.collectionPolicies, mainComposition.applications, LayerBase,
|
mainComposition.root, mainComposition.origins, mainComposition.collectionPolicies, mainComposition.applications, LayerBase,
|
||||||
request.sequencePolicy, declarations,
|
policy.sequencePolicy, declarations,
|
||||||
)
|
)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, append(versionDiagnostics, diagnostics...)
|
return input, append(versionDiagnostics, diagnostics...)
|
||||||
@@ -201,20 +189,33 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
if assembly == nil {
|
if assembly == nil {
|
||||||
return input, versionDiagnostics
|
return input, versionDiagnostics
|
||||||
}
|
}
|
||||||
assembly.current.inputBytes = accounting.bytes
|
assembly.current.inputBytes = state.accounting.bytes
|
||||||
assembly.current.inputNodes = accounting.nodes
|
assembly.current.inputNodes = state.accounting.nodes
|
||||||
if activeOverlay(request.overlay) {
|
if activeOverlay(request.overlay) {
|
||||||
// The selected overlay context is independent of the main input. This
|
if request.overlayFilesystem == nil {
|
||||||
// synthetic descriptor creates no source record or input contribution.
|
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
||||||
selection := Source{kind: sourceBytes}
|
}
|
||||||
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
|
resolved, diagnostic := resolveAbsentFilesystemContext(request.overlayFilesystem)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return input, append(versionDiagnostics, *diagnostic)
|
||||||
|
}
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
return input, append(versionDiagnostics, *diagnostic)
|
||||||
|
}
|
||||||
|
overlayRequest := selectedOverlayRequest{
|
||||||
|
name: request.overlay, filesystem: resolved, protectionRule: request.protectionVersion(),
|
||||||
|
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||||
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
overlayPolicy := []sequencePolicy{request.sequencePolicy}
|
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||||
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState, overlayPolicy...)
|
if diagnostic != nil {
|
||||||
|
return input, append(versionDiagnostics, *diagnostic)
|
||||||
|
}
|
||||||
|
input.sources, diagnostics = loadSelectedOverlay(overlayRequest, access, assembly, input.sources)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, append(versionDiagnostics, diagnostics...)
|
return input, append(versionDiagnostics, diagnostics...)
|
||||||
}
|
}
|
||||||
contexts, diagnostic := contextsForAcquiredSources(selection, request.overlayFilesystem, selected.source, input.sources[len(input.sources)-1:])
|
contexts, diagnostic := contextsForResolvedSources(&resolved, input.sources[len(input.sources)-1:])
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return input, append(versionDiagnostics, *diagnostic)
|
return input, append(versionDiagnostics, *diagnostic)
|
||||||
}
|
}
|
||||||
@@ -225,10 +226,9 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
return input, versionDiagnostics
|
return input, versionDiagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState, policy sequencePolicy) (*acquiredUnderlayGroup, []Diagnostic) {
|
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||||
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
|
||||||
visited := make(map[resourceIdentity]importRoute)
|
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
||||||
hostToken := &namespaceToken{}
|
|
||||||
nativeAnchors := make(map[*namespaceToken]string)
|
nativeAnchors := make(map[*namespaceToken]string)
|
||||||
for inputIndex, contribution := range group.inputs {
|
for inputIndex, contribution := range group.inputs {
|
||||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
if token := nativeTokenForLoadInput(contribution); token != nil {
|
||||||
@@ -240,19 +240,10 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, a
|
|||||||
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
||||||
inclusionGraph := newImportInclusionGraph()
|
inclusionGraph := newImportInclusionGraph()
|
||||||
for inputIndex, contribution := range group.inputs {
|
for inputIndex, contribution := range group.inputs {
|
||||||
options := Options[struct{}]{
|
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||||
Imports: contribution.imports, Filesystem: contribution.filesystem,
|
input: contribution, role: sourceVersionContribution,
|
||||||
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
|
directiveContext: rootDirectiveContextLayered, protectionRule: rule, deferAssembly: true,
|
||||||
placementMode: contribution.placementMode, sequencePolicy: policy, deferAssembly: true,
|
}, policy, state, tier)
|
||||||
directiveContext: rootDirectiveContextLayered,
|
|
||||||
importDefaults: func() importDefaults {
|
|
||||||
if versionState == nil {
|
|
||||||
return importDefaults{}
|
|
||||||
}
|
|
||||||
return snapshotImportDefaults(versionState.importDefaults)
|
|
||||||
}(),
|
|
||||||
}
|
|
||||||
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
|
|
||||||
for _, source := range acquired.sources {
|
for _, source := range acquired.sources {
|
||||||
source.Layer = LayerUnderlay
|
source.Layer = LayerUnderlay
|
||||||
source.UnderlayIndex = index
|
source.UnderlayIndex = index
|
||||||
@@ -286,8 +277,8 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, a
|
|||||||
mergeImportInclusionGraph(&inclusionGraph, inputGraph)
|
mergeImportInclusionGraph(&inclusionGraph, inputGraph)
|
||||||
}
|
}
|
||||||
acquiredGroup.importGraph = inclusionGraph.clone()
|
acquiredGroup.importGraph = inclusionGraph.clone()
|
||||||
acquiredGroup.inputBytes = accounting.bytes - initialBytes
|
acquiredGroup.inputBytes = state.accounting.bytes - initialBytes
|
||||||
acquiredGroup.inputNodes = accounting.nodes - initialNodes
|
acquiredGroup.inputNodes = state.accounting.nodes - initialNodes
|
||||||
return acquiredGroup, nil
|
return acquiredGroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
|||||||
})
|
})
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, accounting, nil, sequencePolicy{})
|
state := &acquisitionState{accounting: accounting}
|
||||||
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
|||||||
t.Fatalf("composed group handoff = %#v", composition)
|
t.Fatalf("composed group handoff = %#v", composition)
|
||||||
}
|
}
|
||||||
|
|
||||||
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, accounting, nil, sequencePolicy{})
|
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||||
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
||||||
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -469,7 +470,8 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, accounting, nil, sequencePolicy{})
|
state := &acquisitionState{accounting: accounting}
|
||||||
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, acquired.sources, acquired.contexts, diagnostics
|
return nil, acquired.sources, acquired.contexts, diagnostics
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,12 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
|||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
access := newNativeAccess(filesystem, "config", namespace.token)
|
access := newNativeAccess(filesystem, "config", namespace.token)
|
||||||
composed, sources, diagnostics := loadImportedWithAccess(access, "config/sub", source.path, parsed, nil, defaultImportLimits(), &importAccounting{})
|
request := testImportRequest(source, nil, parsed, Path{}, nil, nil)
|
||||||
|
policy := &acquisitionPolicy{importLimits: defaultImportLimits()}
|
||||||
|
state := &acquisitionState{accounting: &importAccounting{}}
|
||||||
|
tier := &tierAcquisitionState{}
|
||||||
|
resolved := resolvedFilesystemContext{native: namespace, rootPath: "config", basePath: "config/sub", mainPath: source.path}
|
||||||
|
composed, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
||||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 || sources[1].Path != "config/shared.yml" {
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 || sources[1].Path != "config/shared.yml" {
|
||||||
t.Fatalf("contained imports = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
t.Fatalf("contained imports = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
||||||
}
|
}
|
||||||
@@ -196,7 +201,12 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
_, _, diagnostics = loadImportedWithAccess(newNativeAccess(outside, "config", outsideNamespace.token), "config/sub", "config/sub/main.yml", parsed, nil, defaultImportLimits(), &importAccounting{})
|
request = testImportRequest(filesystemFile(outsideNamespace, "config/sub/main.yml"), nil, parsed, Path{}, nil, nil)
|
||||||
|
policy = &acquisitionPolicy{importLimits: defaultImportLimits()}
|
||||||
|
state = &acquisitionState{accounting: &importAccounting{}}
|
||||||
|
tier = &tierAcquisitionState{}
|
||||||
|
resolved = resolvedFilesystemContext{native: outsideNamespace, rootPath: "config", basePath: "config/sub", mainPath: "config/sub/main.yml"}
|
||||||
|
_, _, diagnostics = acquireImportedWithAccess(request, policy, state, tier, newNativeAccess(outside, "config", outsideNamespace.token), resolved)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||||
t.Fatalf("escaping import diagnostics = %#v", diagnostics)
|
t.Fatalf("escaping import diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,52 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagnostic) {
|
||||||
if len(supplied) > 1 {
|
|
||||||
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
|
|
||||||
}
|
|
||||||
var options Options[T]
|
|
||||||
if len(supplied) == 1 {
|
|
||||||
options = supplied[0]
|
|
||||||
}
|
|
||||||
options.importDefaults = snapshotImportDefaults(options.importDefaults)
|
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
if !options.importDefaultsValidated {
|
if input.placementMode != placementModeMapping && input.placementMode != placementModeSequenceItem {
|
||||||
diagnostics = validateImportDefaults(options.importDefaults)
|
|
||||||
}
|
|
||||||
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
||||||
}
|
}
|
||||||
if normalized, err := normalizeDestination(options.destination); err != nil {
|
if normalized, err := normalizeDestination(input.destination); err != nil {
|
||||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(options.destination))
|
diagnostics = append(diagnostics, invalidDestinationDiagnostic(input.destination))
|
||||||
} else {
|
} else {
|
||||||
options.destination = normalized
|
input.destination = normalized
|
||||||
}
|
}
|
||||||
if options.placementMode == placementModeSequenceItem && len(options.destination.segments) == 0 {
|
if input.placementMode == placementModeSequenceItem && len(input.destination.segments) == 0 {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination"))
|
||||||
}
|
}
|
||||||
definition := options.Definition
|
if input.filesystem != nil {
|
||||||
if !validOverlayName(options.Overlay) {
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(input.source, input.filesystem)...)
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
|
||||||
}
|
}
|
||||||
if options.Filesystem != nil {
|
if input.source.kind == sourceSearch {
|
||||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
for _, issue := range input.source.searchIssues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
|
}
|
||||||
|
if input.source.search == nil {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "configuration search is invalid"))
|
||||||
|
} else {
|
||||||
|
diagnostics = append(diagnostics, validateSearch(*input.source.search, input.source.searchApp)...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if source.kind == sourceNone && options.Imports {
|
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||||
}
|
}
|
||||||
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
if input.filesystem == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
|
||||||
}
|
|
||||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
|
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||||
}
|
}
|
||||||
|
return input, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSettings[T any](defaults importDefaults, definition Definition[T], mode sourceVersionMode) (importDefaults, sequencePolicy, []Diagnostic) {
|
||||||
|
defaults = snapshotImportDefaults(defaults)
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
diagnostics = append(diagnostics, validateImportDefaults(defaults)...)
|
||||||
|
diagnostics = append(diagnostics, sourceVersionOptionDiagnostics(definition, mode)...)
|
||||||
|
policy, policyDiagnostics := compileSequenceIdentityPolicy(definition.sequenceIdentityRegistrations)
|
||||||
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||||
|
return defaults, policy, canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionOptionDiagnostics[T any](definition Definition[T], mode sourceVersionMode) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
if definition.Version != nil {
|
if definition.Version != nil {
|
||||||
if !definition.Version.Path.valid() {
|
if !definition.Version.Path.valid() {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
||||||
@@ -48,12 +55,19 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if options.SourceVersionMode != sourceVersionModeDefault && options.SourceVersionMode != sourceVersionModeIfPresent && options.SourceVersionMode != sourceVersionModeRequired {
|
if mode != sourceVersionModeDefault && mode != sourceVersionModeIfPresent && mode != sourceVersionModeRequired {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid"))
|
||||||
} else if options.SourceVersionMode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
|
} else if mode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker"))
|
||||||
}
|
}
|
||||||
return options, canonicalDiagnostics(diagnostics)
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayOptionDiagnostics(name string) []Diagnostic {
|
||||||
|
if validOverlayName(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen")}
|
||||||
}
|
}
|
||||||
|
|
||||||
func optionDiagnostic(code Code, message string) Diagnostic {
|
func optionDiagnostic(code Code, message string) Diagnostic {
|
||||||
|
|||||||
@@ -129,35 +129,42 @@ func TestPostDecodeOverrideLifecycleMatchesOrdinaryAndLayeredLoads(t *testing.T)
|
|||||||
func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
|
func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
type config struct {
|
type config struct {
|
||||||
Count int `yaml:"count"`
|
Count int `yaml:"count"`
|
||||||
Derived int `yaml:"derived"`
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Derived int `yaml:"derived"`
|
||||||
}
|
}
|
||||||
calls := 0
|
calls := 0
|
||||||
result, err := testLoad(NoSource(), Options[config]{
|
result, err := testLoad(NoSource(), Options[config]{
|
||||||
Definition: Definition[config]{
|
Definition: Definition[config]{
|
||||||
Defaults: func() config {
|
Defaults: func() config {
|
||||||
calls++
|
calls++
|
||||||
return config{Count: 2}
|
return config{Count: 2, Enabled: true}
|
||||||
},
|
},
|
||||||
Resolve: func(value *config, resolution *Resolution) error {
|
Resolve: func(value *config, resolution *Resolution) error {
|
||||||
info, ok := resolution.InputInfo(Keys("count"))
|
for _, key := range []string{"count", "enabled"} {
|
||||||
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
|
info, ok := resolution.InputInfo(Keys(key))
|
||||||
t.Errorf("resolution count info = %#v, %v", info, ok)
|
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
|
||||||
|
t.Errorf("resolution %s info = %#v, %v", key, info, ok)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Default(resolution, Keys("count"), &value.Count, 10, "preserve CLI value")
|
Default(resolution, Keys("count"), &value.Count, 10, "preserve CLI value")
|
||||||
|
Default(resolution, Keys("enabled"), &value.Enabled, true, "preserve CLI value")
|
||||||
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
|
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Overrides: []Override{{Path: Keys("count"), Value: 4, Flag: "--count"}},
|
Overrides: []Override{
|
||||||
|
{Path: Keys("count"), Value: 0, Flag: "--count"},
|
||||||
|
{Path: Keys("enabled"), Value: false, Flag: "--enabled"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if calls != 1 || result.Config.Count != 4 || result.Config.Derived != 12 {
|
if calls != 1 || result.Config.Count != 0 || result.Config.Enabled || result.Config.Derived != 0 {
|
||||||
t.Fatalf("defaults-only CLI result = %#v, defaults calls %d", result.Config, calls)
|
t.Fatalf("defaults-only CLI result = %#v, defaults calls %d", result.Config, calls)
|
||||||
}
|
}
|
||||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Layer != LayerCLI {
|
if sources := result.Sources(); len(sources) != 2 || sources[0].Layer != LayerCLI || sources[1].Layer != LayerCLI {
|
||||||
t.Fatalf("defaults-only CLI sources = %#v", sources)
|
t.Fatalf("defaults-only CLI sources = %#v", sources)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,89 +39,9 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
|
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorStruct:
|
case descriptorStruct:
|
||||||
result, issue := o.node(document.Mapping, path, depth)
|
return o.projectStruct(value, descriptor, path, depth)
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
|
|
||||||
seen := make(map[string]bool, len(descriptor.fields))
|
|
||||||
for _, member := range descriptor.members {
|
|
||||||
memberValue := value.Field(member.index)
|
|
||||||
if member.inline {
|
|
||||||
if isNilValue(memberValue) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inline, issue := o.project(memberValue, member.descriptor, path, depth)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
if inline.Kind != document.Mapping {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
|
|
||||||
}
|
|
||||||
o.nodes-- // The flattened container is not retained in the output tree.
|
|
||||||
for _, entry := range inline.Mapping {
|
|
||||||
if seen[entry.Key] {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
|
|
||||||
}
|
|
||||||
seen[entry.Key] = true
|
|
||||||
result.Mapping = append(result.Mapping, entry)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childPath := path.Key(member.name)
|
|
||||||
if seen[member.name] {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
|
|
||||||
}
|
|
||||||
if issue := o.charge(childPath); issue != nil { // mapping key
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
seen[member.name] = true
|
|
||||||
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
|
||||||
}
|
|
||||||
for _, entry := range result.Mapping {
|
|
||||||
field := descriptor.inlineMap
|
|
||||||
if index, ok := descriptor.fieldByName[entry.Key]; ok {
|
|
||||||
field = &descriptor.fields[index]
|
|
||||||
}
|
|
||||||
if field != nil && field.secret {
|
|
||||||
entry.Value.Sensitive = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
case descriptorMap:
|
case descriptorMap:
|
||||||
if value.IsNil() {
|
return o.projectMap(value, descriptor, path, depth)
|
||||||
return o.node(document.Null, path, depth)
|
|
||||||
}
|
|
||||||
leave, issue := o.enter(value, path)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
defer leave()
|
|
||||||
result, issue := o.node(document.Mapping, path, depth)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
result.Mapping = make([]document.Entry, 0, value.Len())
|
|
||||||
keys := value.MapKeys()
|
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
|
||||||
return cmp.Compare(left.String(), right.String())
|
|
||||||
})
|
|
||||||
for _, key := range keys {
|
|
||||||
childPath := path.Key(key.String())
|
|
||||||
if issue := o.charge(childPath); issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
case descriptorSlice:
|
case descriptorSlice:
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
return o.node(document.Null, path, depth)
|
return o.node(document.Null, path, depth)
|
||||||
@@ -131,22 +51,9 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
return nil, issue
|
return nil, issue
|
||||||
}
|
}
|
||||||
defer leave()
|
defer leave()
|
||||||
fallthrough
|
return o.projectSequence(value, descriptor, path, depth)
|
||||||
case descriptorArray:
|
case descriptorArray:
|
||||||
result, issue := o.node(document.Sequence, path, depth)
|
return o.projectSequence(value, descriptor, path, depth)
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
result.Sequence = make([]*document.Node, 0, value.Len())
|
|
||||||
for i := 0; i < value.Len(); i++ {
|
|
||||||
childPath := path.Index(i)
|
|
||||||
child, issue := o.project(value.Index(i), descriptor.element, childPath, depth+1)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, issue
|
|
||||||
}
|
|
||||||
result.Sequence = append(result.Sequence, child)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
case descriptorDynamic:
|
case descriptorDynamic:
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
return o.node(document.Null, path, depth)
|
return o.node(document.Null, path, depth)
|
||||||
@@ -217,6 +124,111 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) projectStruct(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
|
result, issue := o.node(document.Mapping, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
|
||||||
|
seen := make(map[string]bool, len(descriptor.fields))
|
||||||
|
for _, member := range descriptor.members {
|
||||||
|
memberValue := value.Field(member.index)
|
||||||
|
if member.inline {
|
||||||
|
if isNilValue(memberValue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inline, issue := o.project(memberValue, member.descriptor, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
if inline.Kind != document.Mapping {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
|
||||||
|
}
|
||||||
|
o.nodes-- // The flattened container is not retained in the output tree.
|
||||||
|
for _, entry := range inline.Mapping {
|
||||||
|
if seen[entry.Key] {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
|
||||||
|
}
|
||||||
|
seen[entry.Key] = true
|
||||||
|
result.Mapping = append(result.Mapping, entry)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childPath := path.Key(member.name)
|
||||||
|
if seen[member.name] {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
|
||||||
|
}
|
||||||
|
if issue := o.charge(childPath); issue != nil { // mapping key
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
seen[member.name] = true
|
||||||
|
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
||||||
|
}
|
||||||
|
for _, entry := range result.Mapping {
|
||||||
|
field := descriptor.inlineMap
|
||||||
|
if index, ok := descriptor.fieldByName[entry.Key]; ok {
|
||||||
|
field = &descriptor.fields[index]
|
||||||
|
}
|
||||||
|
if field != nil && field.secret {
|
||||||
|
entry.Value.Sensitive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) projectMap(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
|
if value.IsNil() {
|
||||||
|
return o.node(document.Null, path, depth)
|
||||||
|
}
|
||||||
|
leave, issue := o.enter(value, path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
defer leave()
|
||||||
|
result, issue := o.node(document.Mapping, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||||
|
keys := value.MapKeys()
|
||||||
|
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||||
|
return cmp.Compare(left.String(), right.String())
|
||||||
|
})
|
||||||
|
for _, key := range keys {
|
||||||
|
childPath := path.Key(key.String())
|
||||||
|
if issue := o.charge(childPath); issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) projectSequence(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
|
result, issue := o.node(document.Sequence, path, depth)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Sequence = make([]*document.Node, 0, value.Len())
|
||||||
|
for index := 0; index < value.Len(); index++ {
|
||||||
|
childPath := path.Index(index)
|
||||||
|
child, issue := o.project(value.Index(index), descriptor.element, childPath, depth+1)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, issue
|
||||||
|
}
|
||||||
|
result.Sequence = append(result.Sequence, child)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
|
|||||||
@@ -237,22 +237,6 @@ func SourceInfoImportSelectionsForFacade(source SourceInfo) []ImportSelectionFor
|
|||||||
|
|
||||||
// SourceInfoWithImportSelections copies converted root-owned records into
|
// SourceInfoWithImportSelections copies converted root-owned records into
|
||||||
// private engine source evidence.
|
// private engine source evidence.
|
||||||
func SourceInfoWithImportSelections(source SourceInfo, selections []ImportSelectionForFacade) SourceInfo {
|
|
||||||
if selections == nil {
|
|
||||||
source.importSelections = nil
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
source.importSelections = make([]importSelectionEvidence, len(selections))
|
|
||||||
for index, selection := range selections {
|
|
||||||
source.importSelections[index] = importSelectionEvidence{
|
|
||||||
path: selection.Path.clone(), location: selection.Location,
|
|
||||||
selector: importSelectorFromString(selection.Selector),
|
|
||||||
outcome: importSelectionOutcomeFromString(selection.Outcome),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
func importSelectorString(value importSelector) string {
|
func importSelectorString(value importSelector) string {
|
||||||
switch value {
|
switch value {
|
||||||
case importSelectorPath:
|
case importSelectorPath:
|
||||||
@@ -266,19 +250,6 @@ func importSelectorString(value importSelector) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func importSelectorFromString(value string) importSelector {
|
|
||||||
switch value {
|
|
||||||
case "path":
|
|
||||||
return importSelectorPath
|
|
||||||
case "dir":
|
|
||||||
return importSelectorDirectory
|
|
||||||
case "glob":
|
|
||||||
return importSelectorGlob
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func importSelectionOutcomeString(value importSelectionOutcome) string {
|
func importSelectionOutcomeString(value importSelectionOutcome) string {
|
||||||
if value == importSelectionNone {
|
if value == importSelectionNone {
|
||||||
return "none"
|
return "none"
|
||||||
@@ -286,13 +257,6 @@ func importSelectionOutcomeString(value importSelectionOutcome) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func importSelectionOutcomeFromString(value string) importSelectionOutcome {
|
|
||||||
if value == "none" {
|
|
||||||
return importSelectionNone
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
||||||
// root-owned numeric representation.
|
// root-owned numeric representation.
|
||||||
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||||
@@ -304,13 +268,6 @@ func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool {
|
|||||||
return source.destinationUnresolved
|
return source.destinationUnresolved
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceInfoWithPlacement copies facade-owned source evidence into private fields.
|
|
||||||
func SourceInfoWithPlacement(source SourceInfo, mode uint8, unresolved bool) SourceInfo {
|
|
||||||
source.placementMode = placementMode(mode)
|
|
||||||
source.destinationUnresolved = unresolved
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceVersionCheck is a detached, public-safe record of a source-local
|
// SourceVersionCheck is a detached, public-safe record of a source-local
|
||||||
// version declaration observed during an enabled load.
|
// version declaration observed during an enabled load.
|
||||||
type SourceVersionCheck struct {
|
type SourceVersionCheck struct {
|
||||||
|
|||||||
@@ -241,80 +241,15 @@ func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current refle
|
|||||||
if name != "" {
|
if name != "" {
|
||||||
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
|
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
|
||||||
}
|
}
|
||||||
inlineType := field.Type
|
if issue := b.describeInlineField(descriptor, field, i, secret, path); issue != nil {
|
||||||
seenPointers := make(map[reflect.Type]bool)
|
return issue
|
||||||
for inlineType.Kind() == reflect.Pointer {
|
|
||||||
if seenPointers[inlineType] {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline pointer chain must terminate in a struct"}
|
|
||||||
}
|
|
||||||
seenPointers[inlineType] = true
|
|
||||||
inlineType = inlineType.Elem()
|
|
||||||
}
|
|
||||||
if inlineType.Kind() == reflect.Map && field.Type.Kind() == reflect.Pointer {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline maps cannot be pointers"}
|
|
||||||
}
|
|
||||||
switch inlineType.Kind() {
|
|
||||||
case reflect.Struct:
|
|
||||||
if b.building[inlineType] {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "recursive inline struct is not supported"}
|
|
||||||
}
|
|
||||||
child, childIssue := b.describe(inlineType, path)
|
|
||||||
if childIssue != nil {
|
|
||||||
return childIssue
|
|
||||||
}
|
|
||||||
if child.kind != descriptorStruct {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a structural struct or string-keyed map"}
|
|
||||||
}
|
|
||||||
for _, nested := range child.fields {
|
|
||||||
nested.index = prependIndex(i, nested.index)
|
|
||||||
nested.secret = secret || nested.secret
|
|
||||||
if issue := addField(descriptor, nested, path); issue != nil {
|
|
||||||
return issue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if child.inlineMap != nil {
|
|
||||||
nested := *child.inlineMap
|
|
||||||
nested.index = prependIndex(i, nested.index)
|
|
||||||
nested.secret = secret || nested.secret
|
|
||||||
if descriptor.inlineMap != nil {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
|
|
||||||
}
|
|
||||||
descriptor.inlineMap = &nested
|
|
||||||
}
|
|
||||||
memberType, memberIssue := b.describe(field.Type, path)
|
|
||||||
if memberIssue != nil {
|
|
||||||
return memberIssue
|
|
||||||
}
|
|
||||||
descriptor.members = append(descriptor.members, structMember{index: i, descriptor: memberType, inline: true})
|
|
||||||
case reflect.Map:
|
|
||||||
if inlineType.Key().Kind() != reflect.String {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline map must have string keys"}
|
|
||||||
}
|
|
||||||
if descriptor.inlineMap != nil {
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
|
|
||||||
}
|
|
||||||
fieldPath := path.Key("*")
|
|
||||||
fieldType, childIssue := b.describe(inlineType, fieldPath)
|
|
||||||
if childIssue != nil {
|
|
||||||
return childIssue
|
|
||||||
}
|
|
||||||
descriptor.inlineMap = &fieldDescriptor{index: []int{i}, descriptor: fieldType, secret: secret, inlineMap: true}
|
|
||||||
descriptor.members = append(descriptor.members, structMember{index: i, descriptor: fieldType, inline: true})
|
|
||||||
default:
|
|
||||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a struct, pointer to struct, or string-keyed map"}
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldPath := path.Key(name)
|
if issue := b.describeOrdinaryField(descriptor, field, i, name, secret, path); issue != nil {
|
||||||
fieldType, childIssue := b.describe(field.Type, fieldPath)
|
|
||||||
if childIssue != nil {
|
|
||||||
return childIssue
|
|
||||||
}
|
|
||||||
if issue := addField(descriptor, fieldDescriptor{name: name, index: []int{i}, descriptor: fieldType, secret: secret}, path); issue != nil {
|
|
||||||
return issue
|
return issue
|
||||||
}
|
}
|
||||||
descriptor.members = append(descriptor.members, structMember{index: i, name: name, descriptor: fieldType})
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/engine/schema_fields.go
Normal file
102
internal/engine/schema_fields.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "reflect"
|
||||||
|
|
||||||
|
func (b *schemaBuilder) describeOrdinaryField(owner *typeDescriptor, field reflect.StructField, index int, name string, secret bool, path Path) *schemaIssue {
|
||||||
|
fieldPath := path.Key(name)
|
||||||
|
fieldType, issue := b.describe(field.Type, fieldPath)
|
||||||
|
if issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
if issue := addField(owner, fieldDescriptor{name: name, index: []int{index}, descriptor: fieldType, secret: secret}, path); issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
owner.members = append(owner.members, structMember{index: index, name: name, descriptor: fieldType})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *schemaBuilder) describeInlineField(owner *typeDescriptor, field reflect.StructField, index int, secret bool, path Path) *schemaIssue {
|
||||||
|
inlineType, issue := inlineFieldType(field.Type, path)
|
||||||
|
if issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
if inlineType.Kind() == reflect.Map && field.Type.Kind() == reflect.Pointer {
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline maps cannot be pointers"}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch inlineType.Kind() {
|
||||||
|
case reflect.Struct:
|
||||||
|
return b.describeInlineStruct(owner, field, inlineType, index, secret, path)
|
||||||
|
case reflect.Map:
|
||||||
|
return b.describeInlineMap(owner, inlineType, index, secret, path)
|
||||||
|
default:
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a struct, pointer to struct, or string-keyed map"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inlineFieldType(fieldType reflect.Type, path Path) (reflect.Type, *schemaIssue) {
|
||||||
|
current := fieldType
|
||||||
|
seenPointers := make(map[reflect.Type]bool)
|
||||||
|
for current.Kind() == reflect.Pointer {
|
||||||
|
if seenPointers[current] {
|
||||||
|
return nil, &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline pointer chain must terminate in a struct"}
|
||||||
|
}
|
||||||
|
seenPointers[current] = true
|
||||||
|
current = current.Elem()
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *schemaBuilder) describeInlineStruct(owner *typeDescriptor, field reflect.StructField, inlineType reflect.Type, index int, secret bool, path Path) *schemaIssue {
|
||||||
|
if b.building[inlineType] {
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "recursive inline struct is not supported"}
|
||||||
|
}
|
||||||
|
child, issue := b.describe(inlineType, path)
|
||||||
|
if issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
if child.kind != descriptorStruct {
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a structural struct or string-keyed map"}
|
||||||
|
}
|
||||||
|
for _, nested := range child.fields {
|
||||||
|
nested.index = prependIndex(index, nested.index)
|
||||||
|
nested.secret = secret || nested.secret
|
||||||
|
if issue := addField(owner, nested, path); issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if child.inlineMap != nil {
|
||||||
|
nested := *child.inlineMap
|
||||||
|
nested.index = prependIndex(index, nested.index)
|
||||||
|
nested.secret = secret || nested.secret
|
||||||
|
if owner.inlineMap != nil {
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
|
||||||
|
}
|
||||||
|
owner.inlineMap = &nested
|
||||||
|
}
|
||||||
|
|
||||||
|
memberType, issue := b.describe(field.Type, path)
|
||||||
|
if issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
owner.members = append(owner.members, structMember{index: index, descriptor: memberType, inline: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *schemaBuilder) describeInlineMap(owner *typeDescriptor, inlineType reflect.Type, index int, secret bool, path Path) *schemaIssue {
|
||||||
|
if inlineType.Key().Kind() != reflect.String {
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline map must have string keys"}
|
||||||
|
}
|
||||||
|
if owner.inlineMap != nil {
|
||||||
|
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldPath := path.Key("*")
|
||||||
|
fieldType, issue := b.describe(inlineType, fieldPath)
|
||||||
|
if issue != nil {
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
owner.inlineMap = &fieldDescriptor{index: []int{index}, descriptor: fieldType, secret: secret, inlineMap: true}
|
||||||
|
owner.members = append(owner.members, structMember{index: index, descriptor: fieldType, inline: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -28,69 +28,51 @@ func validOverlayName(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
type selectedOverlayRequest struct {
|
||||||
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
|
name string
|
||||||
allSources, diagnostics := loadSelectedOverlayInto(source, context, main, assembly, sources, name, rule, limits)
|
filesystem resolvedFilesystemContext
|
||||||
if len(diagnostics) != 0 {
|
protectionRule *VersionRule
|
||||||
return nil, allSources, diagnostics
|
sequencePolicy sequencePolicy
|
||||||
}
|
limits importLimits
|
||||||
return assembly.current, allSources, nil
|
versionState *sourceVersionState
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext, main parsedSource, existing *resolvedFilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil)
|
if source.kind == sourceNone {
|
||||||
|
if context == nil {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")
|
||||||
|
return resolvedFilesystemContext{}, &value
|
||||||
|
}
|
||||||
|
return resolveAbsentFilesystemContext(context)
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return *existing, nil
|
||||||
|
}
|
||||||
|
return resolveFilesystemContext(source, context, main, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState, policies ...sequencePolicy) ([]SourceInfo, []Diagnostic) {
|
func openSelectedOverlayAccess(resolved resolvedFilesystemContext) (*filesystemAccess, *Diagnostic) {
|
||||||
var policy sequencePolicy
|
access, err := openResolvedFilesystemAccess(resolved)
|
||||||
if len(policies) != 0 {
|
|
||||||
policy = policies[0]
|
|
||||||
}
|
|
||||||
if nativeContext(source, context) {
|
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
|
|
||||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
if len(policies) == 0 {
|
|
||||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
|
|
||||||
}
|
|
||||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
|
|
||||||
}
|
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
access, err := openHostAccess(rootPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return cloneSourceInfos(sources), []Diagnostic{value}
|
return nil, &value
|
||||||
}
|
}
|
||||||
if len(policies) == 0 {
|
return access, nil
|
||||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
|
|
||||||
}
|
|
||||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo) ([]SourceInfo, []Diagnostic) {
|
||||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil)
|
policy := request.sequencePolicy
|
||||||
}
|
rule := request.protectionRule
|
||||||
|
limits := request.limits
|
||||||
func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState, policies ...sequencePolicy) ([]SourceInfo, []Diagnostic) {
|
versionState := request.versionState
|
||||||
var policy sequencePolicy
|
|
||||||
if len(policies) != 0 {
|
|
||||||
policy = policies[0]
|
|
||||||
}
|
|
||||||
closed := false
|
closed := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if !closed {
|
if !closed {
|
||||||
_ = access.close()
|
_ = access.close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
overlayPath := access.join(access.rootPath, "overlays/"+name+".yml")
|
overlayPath := access.join(access.rootPath, "overlays/"+request.name+".yml")
|
||||||
canonical, err := access.canonical(overlayPath)
|
canonical, err := access.canonical(overlayPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := CodeOverlayInvalid
|
code := CodeOverlayInvalid
|
||||||
@@ -123,7 +105,7 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
|
|||||||
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
sourceID := "source-" + strconv.Itoa(len(sources)+1)
|
||||||
overlayLocation := &Location{SourceID: sourceID}
|
overlayLocation := &Location{SourceID: sourceID}
|
||||||
kind := SourceKindFile
|
kind := SourceKindFile
|
||||||
label := "overlays/" + name + ".yml"
|
label := "overlays/" + request.name + ".yml"
|
||||||
if access.native != nil {
|
if access.native != nil {
|
||||||
kind = SourceKindFilesystem
|
kind = SourceKindFilesystem
|
||||||
label = access.label + "/" + canonical
|
label = access.label + "/" + canonical
|
||||||
@@ -145,66 +127,44 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
|
|||||||
if base.current.inputNodes+nodeCount > limits.nodes {
|
if base.current.inputNodes+nodeCount > limits.nodes {
|
||||||
return sources, []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the aggregate node limit", overlayLocation)}
|
return sources, []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the aggregate node limit", overlayLocation)}
|
||||||
}
|
}
|
||||||
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}, len(policies) != 0); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}, true); len(diagnostics) != 0 {
|
||||||
return sources, diagnostics
|
return sources, diagnostics
|
||||||
}
|
}
|
||||||
var processed sourceVersionProcessingResult
|
var processed sourceVersionProcessingResult
|
||||||
var failures []Diagnostic
|
var failures []Diagnostic
|
||||||
if len(policies) != 0 {
|
if versionState != nil {
|
||||||
if versionState != nil {
|
processed, failures = versionState.processWithDirectiveContext(
|
||||||
processed, failures = versionState.processWithDirectiveContext(
|
overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping, rootDirectiveContextOverlay,
|
||||||
overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping, rootDirectiveContextOverlay,
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
processed = processSourceVersionWithDirectiveContext(
|
|
||||||
overlayRoot, rule, sourceVersionContribution, Path{}, sourceVersionModeDefault, rootDirectiveContextOverlay,
|
|
||||||
)
|
|
||||||
failures = processed.diagnostics
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
processed = processSourceVersionWithDirectiveContext(
|
||||||
processed, failures = versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
|
overlayRoot, rule, sourceVersionContribution, Path{}, sourceVersionModeDefault, rootDirectiveContextOverlay,
|
||||||
} else {
|
)
|
||||||
processed = sourceVersionProcessingResult{payload: overlayRoot}
|
failures = processed.diagnostics
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(failures) != 0 {
|
if len(failures) != 0 {
|
||||||
return sources, failures
|
return sources, failures
|
||||||
}
|
}
|
||||||
overlayPayload := processed.payload
|
overlayPayload := processed.payload
|
||||||
if versionNode := fragmentVersionClaim(overlayPayload, rule); len(policies) == 0 && versionNode != nil {
|
|
||||||
location := publicLocation(versionNode.Location)
|
|
||||||
return sources, []Diagnostic{{
|
|
||||||
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
|
||||||
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
closeErr := access.close()
|
closeErr := access.close()
|
||||||
closed = true
|
closed = true
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var overlayDiagnostics []Diagnostic
|
overlayDiagnostics := base.applyOverlayWithCollectionPolicy(overlayPayload, nil, collectionPolicyScope{}, nil, LayerOverlay, policy, processed.sequences)
|
||||||
if len(policies) != 0 {
|
|
||||||
overlayDiagnostics = base.applyOverlayWithCollectionPolicy(overlayPayload, nil, collectionPolicyScope{}, nil, LayerOverlay, policy, processed.sequences)
|
|
||||||
} else {
|
|
||||||
overlayDiagnostics = base.applyOverlay(overlayPayload, nil, nil, LayerOverlay)
|
|
||||||
}
|
|
||||||
if len(overlayDiagnostics) != 0 {
|
if len(overlayDiagnostics) != 0 {
|
||||||
return sources, overlayDiagnostics
|
return sources, overlayDiagnostics
|
||||||
}
|
}
|
||||||
if len(policies) != 0 {
|
// The selected overlay retains one application with the actual item
|
||||||
// The selected overlay retains one application with the actual item
|
// assignments. Raw list indexes cannot identify an upsert's destination.
|
||||||
// assignments. Raw list indexes cannot identify an upsert's destination.
|
application := base.current.applications[len(base.current.applications)-1]
|
||||||
application := base.current.applications[len(base.current.applications)-1]
|
if node := versionClaimFromApplication(application, rule); node != nil {
|
||||||
if node := versionClaimFromApplication(application, rule); node != nil {
|
location := publicLocation(node.Location)
|
||||||
location := publicLocation(node.Location)
|
return sources, []Diagnostic{{
|
||||||
return sources, []Diagnostic{{
|
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
||||||
Message: "overlays cannot contribute to or replace the schema-version path", Primary: &location,
|
}}
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
base.current.inputBytes += int64(len(data))
|
base.current.inputBytes += int64(len(data))
|
||||||
base.current.inputNodes += nodeCount
|
base.current.inputNodes += nodeCount
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type overlayTestConfig struct {
|
type overlayTestConfig struct {
|
||||||
@@ -21,6 +24,81 @@ type overlayTestConfig struct {
|
|||||||
Schema map[string]string `yaml:"schema"`
|
Schema map[string]string `yaml:"schema"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoSourceSelectedOverlayUsesRootedHostContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
overlayPath := writeImportFile(t, root, "overlays/testing.yml", "resource: asset.txt\n")
|
||||||
|
writeImportFile(t, root, "overlays/asset.txt", "host asset\n")
|
||||||
|
loaded, err := testLoad[config](NoSource(), Options[config]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{Root: root},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Path != canonicalTestPath(t, overlayPath) || sources[0].Layer != LayerOverlay || sources[0].Kind != SourceKindFile {
|
||||||
|
t.Fatalf("NoSource overlay sources = %#v", sources)
|
||||||
|
}
|
||||||
|
context, ok := loaded.Inspection.SourceContext("source-1")
|
||||||
|
if !ok || context.BaseDir != "overlays" {
|
||||||
|
t.Fatalf("overlay resource context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, loaded.Config.Resource))
|
||||||
|
if err != nil || string(data) != "host asset\n" {
|
||||||
|
t.Fatalf("overlay resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSourceSelectedOverlayRequiresContextAndSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{Overlay: "testing"}, CodeOptionsInvalid)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{Root: root},
|
||||||
|
}, CodeOverlayMissing)
|
||||||
|
|
||||||
|
outside := t.TempDir()
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{Root: root, BaseDir: outside},
|
||||||
|
}, CodeImportOutsideRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSourceSelectedOverlayUsesCompleteNativeContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
filesystem := NewFilesystem("embedded", fstest.MapFS{
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("resource: asset.txt\n")},
|
||||||
|
"overlays/asset.txt": &fstest.MapFile{Data: []byte("native asset\n")},
|
||||||
|
})
|
||||||
|
loaded, err := testLoad[config](NoSource(), Options[config]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{FS: &filesystem, BaseDir: ".", Root: "."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Path != "overlays/testing.yml" || sources[0].Layer != LayerOverlay || sources[0].Kind != SourceKindFilesystem {
|
||||||
|
t.Fatalf("native NoSource overlay sources = %#v", sources)
|
||||||
|
}
|
||||||
|
context, ok := loaded.Inspection.SourceContext("source-1")
|
||||||
|
if !ok || context.BaseDir != "overlays" {
|
||||||
|
t.Fatalf("native overlay resource context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, loaded.Config.Resource))
|
||||||
|
if err != nil || string(data) != "native asset\n" {
|
||||||
|
t.Fatalf("native overlay resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) {
|
func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
@@ -507,7 +585,21 @@ func TestLoadOverlayCountsAgainstAggregateBudgets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
base.inputBytes = main.bytes
|
base.inputBytes = main.bytes
|
||||||
base.inputNodes = countInputNodes(main.document)
|
base.inputNodes = countInputNodes(main.document)
|
||||||
_, _, diagnostics = loadSelectedOverlayDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(overlayData)))
|
resolved, diagnostic := resolveFilesystemContext(File(mainPath), nil, main, nil)
|
||||||
|
if diagnostic != nil {
|
||||||
|
t.Fatal(diagnostic)
|
||||||
|
}
|
||||||
|
request := selectedOverlayRequest{
|
||||||
|
name: "testing", filesystem: resolved, limits: test.limits(main, len(overlayData)),
|
||||||
|
}
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
t.Fatal(diagnostic)
|
||||||
|
}
|
||||||
|
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||||
|
if diagnostic != nil {
|
||||||
|
t.Fatal(diagnostic)
|
||||||
|
}
|
||||||
|
_, diagnostics = loadSelectedOverlay(request, access, newAssemblyAccumulator(base), sourceInfos(main.source))
|
||||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
if len(diagnostics) == 0 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,21 @@ const (
|
|||||||
// Source is an opaque configuration input. Construct sources with File,
|
// Source is an opaque configuration input. Construct sources with File,
|
||||||
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
|
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
kind sourceKind
|
kind sourceKind
|
||||||
path string
|
path string
|
||||||
label string
|
label string
|
||||||
namespace *nativeNamespace
|
namespace *nativeNamespace
|
||||||
data []byte
|
data []byte
|
||||||
reader *readerSource
|
reader *readerSource
|
||||||
mapping *document.Node
|
mapping *document.Node
|
||||||
mapIssue *valueIssue
|
mapIssue *valueIssue
|
||||||
mapBytes int64
|
mapBytes int64
|
||||||
mapNodes int
|
mapNodes int
|
||||||
createErr sourceCreationError
|
createErr sourceCreationError
|
||||||
|
search *Search
|
||||||
|
searchApp string
|
||||||
|
searchRequired bool
|
||||||
|
searchIssues []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceKind uint8
|
type sourceKind uint8
|
||||||
@@ -44,6 +48,7 @@ const (
|
|||||||
sourceReader
|
sourceReader
|
||||||
sourceMapping
|
sourceMapping
|
||||||
sourceNone
|
sourceNone
|
||||||
|
sourceSearch
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceCreationError uint8
|
type sourceCreationError uint8
|
||||||
@@ -174,7 +179,9 @@ func Mapping(label string, values map[string]any) Source {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
// NoSource selects an absent input for defaults and resolution. It creates no
|
||||||
|
// source record and has no main declarations to traverse when imports are enabled.
|
||||||
|
// An active overlay requires explicit filesystem context.
|
||||||
func NoSource() Source {
|
func NoSource() Source {
|
||||||
return Source{kind: sourceNone}
|
return Source{kind: sourceNone}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,58 +7,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func contextsForAcquiredSources(source Source, context *FilesystemContext, baseSource *SourceInfo, sources []SourceInfo) (map[string]ResourceContext, *Diagnostic) {
|
func contextsForResolvedSources(resolved *resolvedFilesystemContext, sources []SourceInfo) (map[string]ResourceContext, *Diagnostic) {
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var rootPath, basePath string
|
if resolved == nil {
|
||||||
var filesystem fs.FS
|
|
||||||
if source.kind == sourceFilesystem {
|
|
||||||
filesystem = source.namespace.filesystem
|
|
||||||
rootPath, basePath = ".", path.Dir(source.path)
|
|
||||||
if context != nil && context.Root != "" {
|
|
||||||
rootPath = context.Root
|
|
||||||
}
|
|
||||||
} else if context != nil && context.FS != nil {
|
|
||||||
filesystem = context.FS.namespace.filesystem
|
|
||||||
rootPath, basePath = context.Root, context.BaseDir
|
|
||||||
} else if source.kind == sourceFile {
|
|
||||||
filePath := source.path
|
|
||||||
if baseSource != nil {
|
|
||||||
filePath = baseSource.Path
|
|
||||||
} else {
|
|
||||||
canonical, err := canonicalExistingPath(filePath)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "selected source could not be canonicalized", nil)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
filePath = canonical
|
|
||||||
}
|
|
||||||
basePath = filepath.Dir(filePath)
|
|
||||||
rootPath = basePath
|
|
||||||
if context != nil && context.Root != "" {
|
|
||||||
canonical, err := canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
rootPath = canonical
|
|
||||||
}
|
|
||||||
} else if context != nil {
|
|
||||||
var err error
|
|
||||||
rootPath, err = canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
rootPath, basePath := resolved.rootPath, resolved.basePath
|
||||||
|
var filesystem fs.FS
|
||||||
|
if resolved.native != nil {
|
||||||
|
filesystem = resolved.native.filesystem
|
||||||
|
}
|
||||||
var rooted fs.FS
|
var rooted fs.FS
|
||||||
if filesystem != nil {
|
if filesystem != nil {
|
||||||
rooted = rootedNativeFS{filesystem: filesystem, root: rootPath}
|
rooted = rootedNativeFS{filesystem: filesystem, root: rootPath}
|
||||||
@@ -77,7 +37,7 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
|
|||||||
}
|
}
|
||||||
var relative string
|
var relative string
|
||||||
if filesystem != nil {
|
if filesystem != nil {
|
||||||
if !fs.ValidPath(rootPath) || !fs.ValidPath(base) || rootPath != "." && base != rootPath && !strings.HasPrefix(base, rootPath+"/") {
|
if !containsNativePath(rootPath, base) {
|
||||||
value := importDiagnostic(CodeImportOutsideRoot, "source base is outside the configuration root", nil)
|
value := importDiagnostic(CodeImportOutsideRoot, "source base is outside the configuration root", nil)
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ type sourceVersionState struct {
|
|||||||
localRule *VersionRule
|
localRule *VersionRule
|
||||||
effectiveRule *VersionRule
|
effectiveRule *VersionRule
|
||||||
mode sourceVersionMode
|
mode sourceVersionMode
|
||||||
importDefaults importDefaults
|
|
||||||
mainProcessing *sourceVersionProcessingResult
|
mainProcessing *sourceVersionProcessingResult
|
||||||
evidence []*sourceVersionEvidence
|
evidence []*sourceVersionEvidence
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
|
|||||||
@@ -539,6 +539,41 @@ func TestSourceVersionNoSourceMainRetainsAbsentCallback(t *testing.T) {
|
|||||||
if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
|
if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
|
||||||
t.Fatalf("NoSource result = %#v, callback called = %v", result, called)
|
t.Fatalf("NoSource result = %#v, callback called = %v", result, called)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type config struct{}
|
||||||
|
for _, reject := range []bool{false, true} {
|
||||||
|
called = false
|
||||||
|
definition := Definition[config]{Version: &VersionRule{
|
||||||
|
Path: Keys("version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
called = true
|
||||||
|
if version != (Version{}) {
|
||||||
|
t.Errorf("NoSource load version = %#v", version)
|
||||||
|
}
|
||||||
|
if reject {
|
||||||
|
return errors.New("rejected absent main")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
loaded, loadErr := testLoad(NoSource(), Options[config]{
|
||||||
|
Definition: definition, Imports: true, SourceVersionMode: test.mode,
|
||||||
|
})
|
||||||
|
if !called {
|
||||||
|
t.Fatal("NoSource load did not call the source-version checker")
|
||||||
|
}
|
||||||
|
if reject {
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(loadErr, &failure) {
|
||||||
|
t.Fatalf("rejected NoSource load = %v, want *LoadError", loadErr)
|
||||||
|
}
|
||||||
|
requireSourceVersionDiagnostic(t, failure.Diagnostics(), CodeVersionHookFailed, "/version")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loadErr != nil || loaded == nil || len(loaded.Sources()) != 0 {
|
||||||
|
t.Fatalf("accepted NoSource load = %#v, %v", loaded, loadErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1452,9 +1487,22 @@ func TestSourceVersionOverlayProtectsMountedMainAndPreservesSequenceCoordinates(
|
|||||||
t.Fatalf("compose base: %#v", compositionDiagnostics)
|
t.Fatalf("compose base: %#v", compositionDiagnostics)
|
||||||
}
|
}
|
||||||
assembly := newAssemblyAccumulator(base)
|
assembly := newAssemblyAccumulator(base)
|
||||||
_, diagnostics = loadSelectedOverlayIntoWithVersion(
|
resolved, diagnostic := resolveFilesystemContext(File(mainPath), nil, main, nil)
|
||||||
File(mainPath), nil, main, assembly, sourceInfos(main.source), "testing", effectiveRule, defaultImportLimits(), state,
|
if diagnostic != nil {
|
||||||
)
|
t.Fatalf("resolve overlay context: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
request := selectedOverlayRequest{
|
||||||
|
name: "testing", filesystem: resolved, protectionRule: effectiveRule,
|
||||||
|
limits: defaultImportLimits(), versionState: state,
|
||||||
|
}
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
t.Fatalf("validate overlay context: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||||
|
if diagnostic != nil {
|
||||||
|
t.Fatalf("open overlay context: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
_, diagnostics = loadSelectedOverlay(request, access, assembly, sourceInfos(main.source))
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("load selected overlay: %#v", diagnostics)
|
t.Fatalf("load selected overlay: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -1488,9 +1536,11 @@ func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T)
|
|||||||
limits.nodes = wantNodes
|
limits.nodes = wantNodes
|
||||||
|
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
composed, sources, foundDiagnostics := loadImportedDocumentsTrackedWithVersion(
|
request := testImportRequest(File(mainPath), nil, main, Path{}, rule, nil)
|
||||||
File(mainPath), nil, main, Path{}, rule, limits, accounting, nil, nil, state,
|
policy := acquisitionPolicy{importLimits: limits}
|
||||||
)
|
owners := &acquisitionState{accounting: accounting, versionState: state}
|
||||||
|
tier := &tierAcquisitionState{}
|
||||||
|
composed, sources, foundDiagnostics := testAcquireImport(request, policy, owners, tier)
|
||||||
if len(foundDiagnostics) != 0 || composed == nil || len(sources) != 2 || accounting.nodes != wantNodes || accounting.bytes != wantBytes {
|
if len(foundDiagnostics) != 0 || composed == nil || len(sources) != 2 || accounting.nodes != wantNodes || accounting.bytes != wantBytes {
|
||||||
t.Fatalf("exact raw byte/node accounting = %#v, %d/%d, want %d/%d, %#v", composed, accounting.bytes, accounting.nodes, wantBytes, wantNodes, foundDiagnostics)
|
t.Fatalf("exact raw byte/node accounting = %#v, %d/%d, want %d/%d, %#v", composed, accounting.bytes, accounting.nodes, wantBytes, wantNodes, foundDiagnostics)
|
||||||
}
|
}
|
||||||
@@ -1504,10 +1554,11 @@ func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T)
|
|||||||
below := defaultImportLimits()
|
below := defaultImportLimits()
|
||||||
below.nodes = wantNodes - 1
|
below.nodes = wantNodes - 1
|
||||||
accounting = &importAccounting{}
|
accounting = &importAccounting{}
|
||||||
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
|
request = testImportRequest(File(mainPath), nil, main, Path{}, rule, nil)
|
||||||
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
|
policy.importLimits = below
|
||||||
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
|
owners = &acquisitionState{accounting: accounting, versionState: &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}}
|
||||||
)
|
tier = &tierAcquisitionState{}
|
||||||
|
composed, _, foundDiagnostics = testAcquireImport(request, policy, owners, tier)
|
||||||
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
|
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("raw header nodes were not charged: %#v, %#v", composed, foundDiagnostics)
|
t.Fatalf("raw header nodes were not charged: %#v, %#v", composed, foundDiagnostics)
|
||||||
}
|
}
|
||||||
@@ -1515,10 +1566,11 @@ func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T)
|
|||||||
below = defaultImportLimits()
|
below = defaultImportLimits()
|
||||||
below.bytes = wantBytes - 1
|
below.bytes = wantBytes - 1
|
||||||
accounting = &importAccounting{}
|
accounting = &importAccounting{}
|
||||||
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
|
request = testImportRequest(File(mainPath), nil, main, Path{}, rule, nil)
|
||||||
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
|
policy.importLimits = below
|
||||||
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
|
owners = &acquisitionState{accounting: accounting, versionState: &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}}
|
||||||
)
|
tier = &tierAcquisitionState{}
|
||||||
|
composed, _, foundDiagnostics = testAcquireImport(request, policy, owners, tier)
|
||||||
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
|
if composed != nil || len(foundDiagnostics) != 1 || foundDiagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
t.Fatalf("raw header bytes were not charged: %#v, %#v", composed, foundDiagnostics)
|
t.Fatalf("raw header bytes were not charged: %#v, %#v", composed, foundDiagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,13 +88,6 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if options.Overrides != nil {
|
result.Overrides = toEngineOverrides(options.Overrides)
|
||||||
result.Overrides = make([]engine.Override, len(options.Overrides))
|
|
||||||
for index, override := range options.Overrides {
|
|
||||||
result.Overrides[index] = engine.Override{
|
|
||||||
Path: toEnginePath(override.Path), Value: override.Value, Flag: override.Flag,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
23
lifecycle.go
23
lifecycle.go
@@ -83,8 +83,10 @@ type ImportDefaults struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
// Imports enables traversal of declarations in a selected main document; an
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// absent source has no main declarations to traverse. Overlay selects one fixed
|
||||||
|
// overlay and requires filesystem context for NoSource. Overrides contain only
|
||||||
|
// values explicitly supplied by an application CLI.
|
||||||
// Enabled source-version modes interpret Definition.Version.Path in each
|
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||||
// document's local coordinates. Placement projects checks and main protection
|
// document's local coordinates. Placement projects checks and main protection
|
||||||
// through the input destination; item placement adds the source's item
|
// through the input destination; item placement adds the source's item
|
||||||
@@ -109,9 +111,11 @@ type Options[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
||||||
// BaseDir and Root use absolute host paths. With FS set, they use slash-relative
|
// BaseDir and Root use absolute host paths. For NoSource, Root is required and
|
||||||
// paths in that namespace ("." denotes its root). File sources derive BaseDir
|
// BaseDir may be omitted to use Root as the import base. With FS set, BaseDir
|
||||||
// from the selected file; a native file's explicit Root limits its scope.
|
// and Root use slash-relative paths in that namespace ("." denotes its root).
|
||||||
|
// File sources derive BaseDir from the selected file; a native file's explicit
|
||||||
|
// Root limits its scope.
|
||||||
type FilesystemContext struct {
|
type FilesystemContext struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
Root string
|
Root string
|
||||||
@@ -319,14 +323,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
|||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
||||||
}
|
}
|
||||||
if options.Overrides != nil {
|
result.Overrides = toEngineOverrides(options.Overrides)
|
||||||
result.Overrides = make([]engine.Override, len(options.Overrides))
|
|
||||||
for index := range options.Overrides {
|
|
||||||
result.Overrides[index] = engine.Override{
|
|
||||||
Path: toEnginePath(options.Overrides[index].Path), Value: options.Overrides[index].Value, Flag: options.Overrides[index].Flag,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
records_test.go
129
records_test.go
@@ -1,6 +1,8 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
@@ -27,16 +29,11 @@ func TestOverlayPublicValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
||||||
destination := Keys("collections", "a/b", "0")
|
destination := Keys("collections", "a/b", "0")
|
||||||
public := SourceInfo{
|
path := writeRecordConfig(t, "value: main\n")
|
||||||
ID: "source-1", Destination: destination,
|
options := engine.OptionsWithPlacement(engine.Options[map[string]any]{}, engine.Keys("collections", "a/b", "0"), uint8(PlacementModeSequenceItem))
|
||||||
PlacementMode: PlacementModeSequenceItem, DestinationUnresolved: true,
|
internal := engineSourceInfoFromLoad(t, path, "source-1", options)
|
||||||
}
|
|
||||||
internal := toEngineSourceInfo(public)
|
|
||||||
if internal.Destination.String() != destination.String() || engine.SourceInfoPlacementModeForFacade(internal) != uint8(PlacementModeSequenceItem) || !engine.SourceInfoDestinationUnresolvedForFacade(internal) {
|
|
||||||
t.Fatalf("engine source evidence = %#v", internal)
|
|
||||||
}
|
|
||||||
converted := fromEngineSourceInfo(internal)
|
converted := fromEngineSourceInfo(internal)
|
||||||
if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || !converted.DestinationUnresolved {
|
if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || converted.DestinationUnresolved {
|
||||||
t.Fatalf("public source evidence = %#v", converted)
|
t.Fatalf("public source evidence = %#v", converted)
|
||||||
}
|
}
|
||||||
copy := cloneSourceInfo(converted)
|
copy := cloneSourceInfo(converted)
|
||||||
@@ -47,58 +44,116 @@ func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
|
func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
|
||||||
path := Keys("records").Index(2).Key("version")
|
path := Keys("version")
|
||||||
source := SourceInfo{
|
file := writeRecordConfig(t, "version: '123'\nvalue: main\n")
|
||||||
ID: "source-1",
|
options := engine.Options[recordConfig]{
|
||||||
VersionCheck: &SourceVersionCheck{
|
Definition: engine.Definition[recordConfig]{
|
||||||
Path: path, Version: Version{Present: true, Text: "123"},
|
Version: &engine.VersionRule{
|
||||||
Location: &Location{SourceID: "source-1", Line: 4, Column: 8},
|
Path: engine.Keys("version"),
|
||||||
|
Check: func(version engine.Version, _ *engine.Validation) error {
|
||||||
|
if !version.Present || version.Text != "123" {
|
||||||
|
t.Fatalf("version callback input = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(SourceVersionModeIfPresent)),
|
||||||
}
|
}
|
||||||
internal := toEngineSourceInfo(source)
|
internal := engineSourceInfoFromLoad(t, file, "source-1", options)
|
||||||
if internal.VersionCheck == nil || internal.VersionCheck.Path.String() != path.String() ||
|
|
||||||
internal.VersionCheck.Version.Text != "123" || internal.VersionCheck.Location == nil || internal.VersionCheck.Location.Line != 4 {
|
|
||||||
t.Fatalf("engine source version check = %#v", internal.VersionCheck)
|
|
||||||
}
|
|
||||||
converted := fromEngineSourceInfo(internal)
|
converted := fromEngineSourceInfo(internal)
|
||||||
|
if converted.VersionCheck == nil || converted.VersionCheck.Path.String() != path.String() ||
|
||||||
|
!converted.VersionCheck.Version.Present || converted.VersionCheck.Version.Text != "123" ||
|
||||||
|
converted.VersionCheck.Location == nil || converted.VersionCheck.Location.SourceID != internal.ID || converted.VersionCheck.Location.Line != 1 {
|
||||||
|
t.Fatalf("public source version check = %#v", converted.VersionCheck)
|
||||||
|
}
|
||||||
copy := cloneSourceInfo(converted)
|
copy := cloneSourceInfo(converted)
|
||||||
copy.VersionCheck.Path = Keys("changed")
|
copy.VersionCheck.Path = Keys("changed")
|
||||||
copy.VersionCheck.Version.Text = "changed"
|
copy.VersionCheck.Version.Text = "changed"
|
||||||
copy.VersionCheck.Location.Line = 99
|
copy.VersionCheck.Location.Line = 99
|
||||||
copy.VersionCheck.Redacted = true
|
copy.VersionCheck.Redacted = true
|
||||||
if converted.VersionCheck.Path.String() != path.String() || converted.VersionCheck.Version.Text != "123" ||
|
if converted.VersionCheck.Path.String() != path.String() || converted.VersionCheck.Version.Text != "123" ||
|
||||||
converted.VersionCheck.Location.Line != 4 || converted.VersionCheck.Redacted {
|
converted.VersionCheck.Location.Line != 1 || converted.VersionCheck.Redacted {
|
||||||
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportSelectionConversionsAndCopies(t *testing.T) {
|
func TestImportSelectionConversionsAndCopies(t *testing.T) {
|
||||||
declaration, err := ParsePath("/configkit/imports/2")
|
file := writeRecordConfig(t, "configkit:\n imports:\n - glob: absent/*.yml\nvalue: main\n")
|
||||||
if err != nil {
|
internal := engineSourceInfoFromLoad(t, file, "source-1", engine.Options[recordConfig]{Imports: true})
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
source := SourceInfo{ID: "source-4", ImportSelections: []ImportSelection{{
|
|
||||||
Path: declaration, Location: Location{SourceID: "source-4", Line: 7, Column: 5},
|
|
||||||
Selector: ImportSelectorGlob, Outcome: ImportSelectionNone,
|
|
||||||
}}}
|
|
||||||
internal := toEngineSourceInfo(source)
|
|
||||||
transport := engine.SourceInfoImportSelectionsForFacade(internal)
|
|
||||||
if len(transport) != 1 || transport[0].Path.String() != declaration.String() || transport[0].Location.SourceID != source.ID || transport[0].Selector != "glob" || transport[0].Outcome != "none" {
|
|
||||||
t.Fatalf("engine selection evidence = %#v", transport)
|
|
||||||
}
|
|
||||||
source.ImportSelections[0].Path = Keys("changed")
|
|
||||||
converted := fromEngineSourceInfo(internal)
|
converted := fromEngineSourceInfo(internal)
|
||||||
if len(converted.ImportSelections) != 1 || converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Location.Line != 7 || converted.ImportSelections[0].Selector != ImportSelectorGlob || converted.ImportSelections[0].Outcome != ImportSelectionNone {
|
if len(converted.ImportSelections) != 1 || converted.ImportSelections[0].Path.String() != "/configkit/imports/0" ||
|
||||||
|
converted.ImportSelections[0].Location.SourceID != internal.ID || converted.ImportSelections[0].Location.Line != 3 ||
|
||||||
|
converted.ImportSelections[0].Selector != ImportSelectorGlob || converted.ImportSelections[0].Outcome != ImportSelectionNone {
|
||||||
t.Fatalf("converted selection evidence = %#v", converted.ImportSelections)
|
t.Fatalf("converted selection evidence = %#v", converted.ImportSelections)
|
||||||
}
|
}
|
||||||
copy := cloneSourceInfo(converted)
|
copy := cloneSourceInfo(converted)
|
||||||
copy.ImportSelections[0].Path = Keys("copy")
|
copy.ImportSelections[0].Path = Keys("copy")
|
||||||
copy.ImportSelections[0].Selector = ImportSelectorPath
|
copy.ImportSelections[0].Selector = ImportSelectorPath
|
||||||
if converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Selector != ImportSelectorGlob {
|
if converted.ImportSelections[0].Path.String() != "/configkit/imports/0" || converted.ImportSelections[0].Selector != ImportSelectorGlob {
|
||||||
t.Fatalf("source selection evidence changed through copy: %#v", converted.ImportSelections)
|
t.Fatalf("source selection evidence changed through copy: %#v", converted.ImportSelections)
|
||||||
}
|
}
|
||||||
|
if converted.ImportSelections[0].Outcome != ImportSelectionNone {
|
||||||
|
t.Fatalf("source selection outcome = %q", converted.ImportSelections[0].Outcome)
|
||||||
|
}
|
||||||
|
if fromEngineImportSelections(nil) != nil {
|
||||||
|
t.Fatal("converting nil engine selections returned a non-nil slice")
|
||||||
|
}
|
||||||
|
if fromEngineImportSelections([]engine.ImportSelectionForFacade{}) == nil {
|
||||||
|
t.Fatal("converting an empty engine selection slice returned nil")
|
||||||
|
}
|
||||||
empty := cloneSourceInfo(SourceInfo{ImportSelections: make([]ImportSelection, 0)})
|
empty := cloneSourceInfo(SourceInfo{ImportSelections: make([]ImportSelection, 0)})
|
||||||
if empty.ImportSelections == nil {
|
if empty.ImportSelections == nil {
|
||||||
t.Fatal("cloning collapsed a non-nil empty selection slice")
|
t.Fatal("cloning collapsed a non-nil empty selection slice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOverrideConversionsPreserveSliceShapeAndCopyRecords(t *testing.T) {
|
||||||
|
if toEngineOverrides(nil) != nil {
|
||||||
|
t.Fatal("nil overrides converted to a non-nil slice")
|
||||||
|
}
|
||||||
|
empty := make([]Override, 0)
|
||||||
|
if toEngineOverrides(empty) == nil || toEngineOptions(Options[recordConfig]{Overrides: empty}).Overrides == nil ||
|
||||||
|
toEngineLayeredOptions(LayeredOptions[recordConfig]{Overrides: empty}).Overrides == nil {
|
||||||
|
t.Fatal("a non-nil empty override slice was collapsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides := []Override{{Path: Keys("feature", "enabled"), Value: "yes", Flag: "--feature-enabled"}}
|
||||||
|
ordinary := toEngineOptions(Options[recordConfig]{Overrides: overrides})
|
||||||
|
layered := toEngineLayeredOptions(LayeredOptions[recordConfig]{Overrides: overrides})
|
||||||
|
overrides[0] = Override{Path: Keys("changed"), Value: "no", Flag: "--changed"}
|
||||||
|
for label, converted := range map[string][]engine.Override{"ordinary": ordinary.Overrides, "layered": layered.Overrides} {
|
||||||
|
if len(converted) != 1 || converted[0].Path.String() != "/feature/enabled" || converted[0].Value != "yes" || converted[0].Flag != "--feature-enabled" {
|
||||||
|
t.Errorf("%s overrides = %#v", label, converted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRecordConfig(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func engineSourceInfoFromLoad[T any](t *testing.T, path, sourceID string, options engine.Options[T]) engine.SourceInfo {
|
||||||
|
t.Helper()
|
||||||
|
loaded, err := engine.Load(engine.File(path), engine.SecretBinding{}, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("engine load: %v", err)
|
||||||
|
}
|
||||||
|
for _, source := range loaded.Inspection.Sources() {
|
||||||
|
if source.ID == sourceID {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("engine inspection contains no source %q", sourceID)
|
||||||
|
return engine.SourceInfo{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Source is an opaque configuration input. Construct sources with File,
|
// Source is an opaque configuration input. Construct sources with File,
|
||||||
// Filesystem.File, Bytes, Reader, Mapping, or NoSource; the zero value is invalid.
|
// Filesystem.File, SearchConfig, Bytes, Reader, Mapping, or NoSource; the zero
|
||||||
|
// value is invalid. SearchConfig selects a host file when a load consumes it.
|
||||||
type Source struct{ value engine.Source }
|
type Source struct{ value engine.Source }
|
||||||
|
|
||||||
// Filesystem is a reusable opaque namespace for a caller-owned fs.FS. Copies
|
// Filesystem is a reusable opaque namespace for a caller-owned fs.FS. Copies
|
||||||
@@ -47,7 +48,9 @@ func Mapping(label string, values map[string]any) Source {
|
|||||||
return Source{value: engine.Mapping(label, values)}
|
return Source{value: engine.Mapping(label, values)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
// NoSource selects an absent input for defaults and resolution. It creates no
|
||||||
|
// source record and has no main declarations to traverse when imports are enabled.
|
||||||
|
// An active overlay requires explicit filesystem context.
|
||||||
func NoSource() Source { return Source{value: engine.NoSource()} }
|
func NoSource() Source { return Source{value: engine.NoSource()} }
|
||||||
|
|
||||||
func (s Source) toEngine() engine.Source { return s.value }
|
func (s Source) toEngine() engine.Source { return s.value }
|
||||||
|
|||||||
Reference in New Issue
Block a user