54 Commits

Author SHA1 Message Date
50199eb0af Retire completed consolidation roadmap documents 2026-10-25 15:09:34 +00:00
34cbc0d63b Document ConfigKit v0.10.1 2026-10-25 15:07:06 +00:00
5e12b2d360 Retire the completed clone traversal review 2026-10-25 14:58:56 +00:00
8dfaf44d1d Index import dependencies for local scope scheduling 2026-10-25 14:41:07 +00:00
64be8b4df5 Keep import selection evidence with its declaring source 2026-10-25 14:36:39 +00:00
e585decdb2 Reuse validated filesystem facts through acquisition 2026-10-25 14:33:17 +00:00
6440a500f7 Record import bookkeeping scalability findings 2026-10-25 14:29:40 +00:00
d898cb193d Record the remaining context consolidation gap 2026-10-25 14:00:29 +00:00
557e006a4a Complete consolidation roadmap validation 2026-10-25 13:51:36 +00:00
3fd83b6b2b Document consumer and internal loading flows 2026-10-25 13:49:44 +00:00
0fbce5d141 Separate schema field discovery and assembly 2026-10-25 13:46:51 +00:00
ac4ce242a2 Separate reflection projection handlers 2026-10-25 13:45:27 +00:00
fa6e876ab5 Extract inheritance accounting and finalization 2026-10-25 13:43:26 +00:00
c5275df1fc Return inheritance combination evidence together 2026-10-25 13:41:41 +00:00
f404168900 Extract inheritance input preparation 2026-10-25 13:32:01 +00:00
729c6fb5d5 Extract imported source admission from traversal 2026-10-25 13:28:43 +00:00
d314a269d5 Separate directory entry admission from traversal 2026-10-25 13:25:37 +00:00
2c3eb3308b Extract import declaration parsing helpers 2026-10-25 13:23:09 +00:00
6bc625b68f Split import acquisition and projection responsibilities 2026-10-25 13:21:29 +00:00
b915dd776a Consolidate selected overlay acquisition 2026-10-25 13:18:54 +00:00
9a64a65b6c Carry resolved filesystem facts through loads 2026-10-25 13:13:47 +00:00
6158e9db3b Separate filesystem context from access 2026-10-25 13:09:44 +00:00
0d149e51f0 Wire load lifecycle through input acquisition 2026-10-25 13:07:16 +00:00
bd51b9c3f2 Consolidate import request ownership 2026-10-25 13:02:46 +00:00
d05426d8a2 Introduce non-generic input acquisition requests 2026-10-25 12:55:04 +00:00
3f0efcb4ff Normalize ordinary and grouped load preflight 2026-10-25 12:52:50 +00:00
19f5c2a2d5 Remove unused reverse source conversions 2026-10-25 12:50:11 +00:00
455a4a355d Map ConfigKit consolidation callers and coverage 2026-10-25 12:37:33 +00:00
8ab1568352 Plan the ConfigKit consolidation sprint 2026-10-25 12:14:01 +00:00
8a99e61070 Defer clone traversal review to future work 2026-10-25 11:54:46 +00:00
de7513d488 Settle retaining the public loading APIs 2026-10-24 23:07:09 +00:00
26771dc03e Consolidate refactoring roadmaps and record open questions 2026-10-24 22:04:26 +00:00
fbaaaf18cd Draft the ConfigKit consolidation roadmap toward 1.0 2026-10-24 21:51:29 +00:00
c9574ef3c1 Record complexity audit findings 2026-10-24 19:54:13 +00:00
5959537d1a Prepare the ConfigKit v0.10.0 release 2026-10-24 19:45:33 +00:00
0369111d30 Resolve Windows rooted search paths with Go 2026-10-24 19:24:30 +00:00
2fedb0307f Use Go rules for Windows filename components 2026-10-24 19:20:54 +00:00
087050fa25 Isolate user configuration discovery tests by platform 2026-10-24 19:18:13 +00:00
1ac1f8bc9e Preserve failing search candidate diagnostics 2026-10-24 19:16:50 +00:00
4dc3e70877 Validate search filesystem context after selection 2026-10-24 19:13:35 +00:00
f64497cb13 Record discovery algorithm audit findings 2026-10-24 19:03:04 +00:00
68cf5c489b Record configuration discovery review findings 2026-10-24 17:52:20 +00:00
e50fee2ca5 Complete the configuration discovery implementation plan 2026-10-24 16:26:09 +00:00
eec214944c Align discovery ownership and contributor documentation 2026-10-24 16:24:57 +00:00
4f2c113237 Extend discovery coverage across consumer workflows 2026-10-24 16:20:53 +00:00
2d93a5c703 Expose configuration search through the public facade 2026-10-24 16:16:07 +00:00
2f6c1eca00 Integrate configuration searches into engine loading 2026-10-24 16:11:03 +00:00
8642337966 Load ordinary overlays without a main source 2026-10-24 15:59:39 +00:00
53ce6c63d8 Allow absent inputs to use import contexts 2026-10-24 15:55:10 +00:00
2f0900535d Select configuration files through host rules 2026-10-24 15:46:36 +00:00
4748313105 Expand ordered configuration search candidates 2026-10-24 15:43:10 +00:00
e2634c8815 Define immutable configuration searches 2026-10-24 15:39:59 +00:00
424c89780e Plan configuration discovery implementation 2026-10-24 15:04:02 +00:00
92309ddcc3 Update the discovery roadmap baseline 2026-10-24 14:54:56 +00:00
66 changed files with 5662 additions and 3035 deletions

View File

@@ -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
[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
type Config struct {
Enabled bool `yaml:"enabled"`

View File

@@ -52,16 +52,6 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
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 {
if values == nil {
return nil
@@ -76,15 +66,14 @@ func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []Impo
return result
}
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
func toEngineOverrides(values []Override) []engine.Override {
if values == nil {
return nil
}
result := make([]engine.ImportSelectionForFacade, len(values))
result := make([]engine.Override, len(values))
for index, value := range values {
result[index] = engine.ImportSelectionForFacade{
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
Selector: string(value.Selector), Outcome: string(value.Outcome),
result[index] = engine.Override{
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
}
}
return result
@@ -127,16 +116,6 @@ func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersi
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 {
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
if value.Inheritance != nil {
@@ -150,19 +129,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
}
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 {
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
View 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...)}
}

View 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/`.

View File

@@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors:
| 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. |
| `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. |
| `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. |
@@ -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
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
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
@@ -78,9 +152,14 @@ non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
source-root collection declarations whether or not imports are enabled; an
`imports` declaration still requires imports to be enabled. `NoSource` rejects
imports, active overlays, and filesystem context as invalid combinations but
accepts overrides and remains an absent source rather than an empty document.
`imports` declaration still requires imports to be enabled. `NoSource` may enable
imports, but it has no main declarations to traverse. It accepts an explicit
filesystem context: a host context requires an absolute existing `Root` and may
omit `BaseDir` to use that root as its internal import base; a native context
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
when imports are disabled. An active ordinary overlay requires this explicit
context. `NoSource` accepts overrides and remains absent rather than contributing
an empty document.
Every source, options, schema, version, defaults, decode, resolution, or validation
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
follow the selected-overlay rules below; inactive overlays ignore the context.
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
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,
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
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

View File

@@ -6,6 +6,30 @@ applications. Consumers still own their configuration structs, defaults, semanti
validation, schema-version policy, CLI parsing, runtime matching and conversion,
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
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
execution profile concepts.
## Explicit main-file discovery
Applications choose the search conventions and their precedence, then pass the
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
For example, the maintained sequence catalog puts a caller-selected file before
a custom directory fallback while keeping its underlay and overlay choices
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
the [working-directory executable example](../../examples/consumers/discovery_test.go).
Together, the root-owned [discovery API](../../discovery.go) and [configuration
discovery contract](../config.md#configuration-discovery) define ordered
concatenation, directory-first Cartesian expansion, optional exhaustion, and
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records why discovery is explicitly selected.
## Import defaults and selection inspection
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one

View File

@@ -63,6 +63,7 @@ Accepted decisions:
- [Separate import selection controls](adr/0016-independent-import-controls.md)
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -182,6 +183,21 @@ Current behavior belongs to the configuration contract's
The completed remediation record, including the algorithm-audit findings, is
preserved at `ea46001:docs/roadmap/remediation.md`.
The completed configuration-discovery roadmap, implementation plan, and
remediation record are preserved in Git history at `0369111`, for example
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
[configuration discovery](config.md#configuration-discovery); the public
[discovery declarations](../discovery.go) own the Go API.
The completed consolidation proposal, implementation plan, and resolved audit
findings are preserved in Git history at `34cbc0d` under `docs/roadmap/`. For
example:
```sh
git show 34cbc0d:docs/roadmap/implementation.md
git show 34cbc0d:docs/roadmap/remediation.md
```
Potential enhancements are tracked in [future features](roadmap/future.md).
Install the Go version required by `go.mod` or a compatible newer toolchain.
@@ -230,16 +246,17 @@ code changes. Use the component overview to find the corresponding implementatio
| 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'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| 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` |
| 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'` |
| 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` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| 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'` |

View File

@@ -7,7 +7,11 @@ This note records implementation flow and private identities for maintainers.
## Discovery and acquisition
Each source's root directive is parsed into presence-aware declarations and
resolved against load-wide defaults. Candidate selection preserves a normalized
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
policy, load-wide accounting, and tier-scoped repetition/namespace state;
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
and import admission. Candidate
selection in `import_selection.go` preserves a normalized
namespace path separately from canonical access identity: overlapping roots
coalesce the same candidate spelling in that namespace, while aliases reached
through distinct spellings retain separate inclusion routes for admission.
@@ -20,10 +24,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
only a back-edge to an active directory is a traversal cycle; a later alias is
not mistaken for one. This is separate from import dependency-cycle detection.
For host sources, discovery uses rooted `Lstat` and canonical containment. Native
sources use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows
the capabilities and behavior of the provided `fs.FS`, without claiming a
general sandbox guarantee.
For host sources, discovery uses rooted `Lstat` and canonical containment.
`filesystem_context.go` owns resolved access facts and live access setup; the
acquisition operation closes its live handles before composition. Native sources
use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
capabilities and behavior of the provided `fs.FS`, without claiming a general
sandbox guarantee. `source_context.go` derives passive resource contexts from
resolved sources for later caller-owned reads; those records do not retain live
acquisition handles.
Acquisition remains authored: declarations are visited in source order and each
selected candidate set in namespace-path lexical order. This preserves source
@@ -64,6 +72,12 @@ tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
returns a closed cycle rotated to its smallest key; blocked dependents are not
reported as cycle members.
Import edge preparation builds an importer-to-target adjacency index once for
an acquisition's outer and item-local scheduling, and once for grouped
scheduling. Each scope gathers edges from its participating identities and
retains only edges with both endpoints in its local slots. The retained
inclusion graph still owns cycle diagnostics and source relationships.
Import composition schedules a main tier or one underlay group as a single
strict-scope graph. Applicable collection declarations are gathered for the
scope before peer composition, including identity inherited from lower tiers.
@@ -78,10 +92,14 @@ mapping order in its output.
These scopes do not flatten into one global graph, and independent precedence
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),
[`import_graph.go`](../../internal/engine/import_graph.go),
[`import_projection.go`](../../internal/engine/import_projection.go),
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
Their focused behavior is covered by import-selection, scheduler, and scheduling
integration tests under `internal/engine`.
Their focused behavior is covered by import-selection, acquisition, scheduler,
and scheduling integration tests under `internal/engine`; see the
[development test map](../development.md#task-to-test-map).

View File

@@ -7,10 +7,13 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners:
- `internal/engine` owns host and native-filesystem source acquisition,
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
- `internal/engine` owns host and native-filesystem source acquisition and
explicitly selected configuration discovery. A search resolves once during
shared acquisition; the selected concrete file or absent-input state then
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
collection operation at precedence boundaries and owns dependency-ready,
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;
`Result.Config` is detached and its private inspection remains frozen
independently.
- Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods.
- Root conversion files explicitly cross the path, record, callback, secret,
search-description, and error boundaries. There are no public aliases,
anonymous engine embeddings, or public engine bridge methods.
- Root `Diff` and `Difference` provide the public comparison boundary and
convert detached engine records to root-owned paths and snapshots; consumer
examples own presentation of the safe changed regions.
@@ -53,7 +56,9 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types.
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
[effective configuration diffs](../config.md#effective-configuration-diffs).
The [strict composition contract](../config.md#strict-document-composition)
@@ -78,5 +83,64 @@ records the shared dependency-ready ordering decision; [ADR
0016](../adr/0016-independent-import-controls.md) records the independent
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
operations and CLI ordering. The [import internals](imports.md) document candidate and
namespace identities, discovery and scheduling, and the bounded algorithms.
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records explicit main-file discovery. The [import internals](imports.md)
document candidate and namespace identities, discovery and scheduling, and the
bounded algorithms.
## Implementation flow
The root facade converts caller-owned values into engine-owned requests. 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, load-wide accounting, and tier-scoped repetition/namespace state. Each
input is acquired under that policy; grouped requests retain acquired underlay
scopes until their tier is composed in
[`load_request.go`](../../internal/engine/load_request.go). Live
filesystem access handles are confined to acquisition and closed there. Resolved
filesystem facts used to authorize access are separate from passively retained
resource contexts. Non-import validation retains those facts on `assembledInput`
for passive-context construction and ordinary-overlay handling: see
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
[`source_context.go`](../../internal/engine/source_context.go).
Import responsibilities are split across candidate selection and path
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
source reading and traversal admission in
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
and projection of acquired evidence into final source coordinates in
[`import_projection.go`](../../internal/engine/import_projection.go). The
[import internals](imports.md) page describes the boundaries and identities.
After acquisition handles are closed, grouped composition and inheritance run on
the retained ordered documents. `inheritance.go` coordinates traversal and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
dependency-ready templates;
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
prepares child and parent inputs,
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
returns the combined value with its origin, policy, sequence, and diagnostic
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
accounts for and finalizes the result. Projection and schema work have separate
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
descriptor caching and type policy, and
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
those descriptors for typed decoding.
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
covered by `imports_test.go`, `load_request_test.go`, and focused source,
discovery, and destination tests; inheritance by the inheritance test files;
reflection behavior by `schema_test.go`, `projection_test.go`, and
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
copying, using the shared descriptors and per-operation traversal helpers.

View File

@@ -144,8 +144,12 @@ remains in the roadmap until implemented.
ConfigKit owns configuration input processing and the coordination of decoding,
resolution, validation, and inspection. Applications own their configuration
types, field meanings, defaults, normalization, semantic validation, and schema
version policy. Applications also own main-file search conventions, CLI wiring,
remote acquisition, runtime initialization, and resource checks.
version policy. Applications choose and order their main-file search conventions;
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
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.
Library code must not depend on consuming applications or initialize their
runtime resources. It must not perform implicit network access, discover
application-specific paths, or change process-global state during ordinary `Load`.
runtime resources. It must not perform implicit network access, implicitly
discover application-specific paths, or change process-global state during
ordinary `Load`.
Import discovery is available only through declarations in selected sources:
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
ConfigKit does not search conventional application paths 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
dependency-ready scheduling within their existing scopes; acquisition order and
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)

77
docs/releases/v0.10.0.md Normal file
View File

@@ -0,0 +1,77 @@
# ConfigKit v0.10.0
This minor release adds explicit, composable discovery of a configuration file.
Applications choose the search conventions and their order; ConfigKit selects one
file during loading and returns the final configuration with its normal metadata.
Optional searches that find no file share the expanded `NoSource()` lifecycle.
## Changes
- Add `SearchConfig`, immutable `Search` and `FilenameGroup` descriptions, and
search-wide requiredness through `SearchOptions`. Compose explicit `Paths`,
custom `SearchDirectory` descriptions, and built-in Unix system, user-config,
home, or working-directory presets with `Searches`. See the
[public declarations](../../discovery.go) and
[discovery contract](../config.md#configuration-discovery).
- Expand searches in supplied order, trying a directory's filename groups before
moving to the next directory. Named and generic groups provide conventional
YAML filenames; custom groups provide literal names. Directory searches
distinguish omitted filename selection from an explicitly empty selection.
Constructors perform no filesystem access, and each load observes current
directory bases and file availability.
- Select the first existing candidate. Access errors and failures while loading
the selected file terminate the load rather than trying another configuration.
Selected files retain physical source identity, placement, provenance, imports,
and relative-resource context. Discovery works with ordinary loading and
layered inputs, including underlays, without adding a precedence tier.
- Permit enabled imports with an absent main input: there are no main import
declarations to traverse, while actual underlay sources retain normal imports.
Support ordinary selected overlays with an absent main and explicit filesystem
context, including a root-only host context. Layered overlays retain their
independent `OverlayFilesystem` requirement. See
[single-source loading](../config.md#single-source-loading) and
[layered loading](../config.md#layered-loading).
- Preserve ordinary file-context behavior after discovery and include the
failing candidate's file identity in access diagnostics. Use Go's
platform-aware Windows component rules and native drive-root-relative path
resolution. Isolate user-config tests according to the host Go conventions.
## Compatibility and upgrade
Existing explicit source constructors and loading APIs remain available.
`File(path)` remains an exclusive selection that fails for a missing file;
discovery is activated only by explicitly supplying `SearchConfig`.
Searches are optional by default. Set `SearchOptions{Required: true}` when the
application must find a file. An exhausted optional search contributes no main
document or source record; application version checks, defaults, resolution,
and validation still run under their existing rules and may reject incomplete
configuration. A selected overlay is still required to exist.
The former `NoSource()` rejections of enabled imports and explicit filesystem
context are relaxed. An active ordinary overlay with no main file now loads
when explicit context is supplied. Applications that relied on those combinations
being rejected should review their option handling. Selected host files derive
their own base directory; do not supply an explicit `BaseDir` or native filesystem
context for a search that selects a host file.
Applications may replace their own fallback-loading loops with the new search
descriptions. The maintained [consumer examples](../consumers/README.md#explicit-main-file-discovery)
show simple preset selection and custom-first discovery alongside existing
configuration tiers. Composition, collection operations, inheritance, CLI
precedence, and the complete-on-return ownership model retain their existing
semantics.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.0
```
The consumer minimum remains Go 1.26.0. Release validation uses Go 1.26.10.
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer
tool remains ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated
targets; macOS remains best-effort. Publication follows the existing
[release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/docs/oss/attribution.md)

42
docs/releases/v0.10.1.md Normal file
View File

@@ -0,0 +1,42 @@
# ConfigKit v0.10.1
This patch release consolidates ConfigKit's internal loading machinery and
reduces repeated import bookkeeping. Public APIs and configuration semantics
remain unchanged from v0.10.0.
## Changes
- Consolidate ordinary and layered input acquisition, including import requests
and selected-overlay handling, while retaining their distinct context and
precedence rules. Remove unused private reverse conversions.
- Separate import acquisition, scoped composition, and evidence projection;
clarify inheritance preparation, combination, and finalization; and simplify
reflective projection and schema-field assembly responsibilities.
- Retain resolved filesystem facts from non-import validation for later passive
resource contexts and ordinary overlays. Remove duplicate absent-host context
derivation while preserving validation timing and handle cleanup.
- Record empty optional-import selections directly against their declaring
source, avoiding repeated searches through acquired sources. Index import
dependencies for scope-local edge preparation instead of scanning the whole
inclusion graph for every item scope. Stable dependency-ready ordering,
provenance, and source destinations are preserved.
- Expand regression coverage for import evidence ownership, scope boundaries,
and broad main/underlay item imports. Improve
[consumer loading guidance](../consumers/README.md) and internal documentation.
## Compatibility and upgrade
No consumer code or configuration migration is required. Composition, collection
operations, inheritance, discovery, CLI precedence, source-version checks, and
inspection retain their existing contracts.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.1
```
The consumer minimum remains Go 1.26.0. Dependencies and platform support are
unchanged. Publication follows the existing [release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/docs/oss/attribution.md)

View File

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

View File

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

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

View File

@@ -31,13 +31,23 @@ type SequenceCatalogEntry struct {
Credential configkit.Secret `yaml:"credential"`
}
// LoadSequenceCatalog assembles imported main fragments over a file-backed
// underlay, applies one selected collection overlay, then resolves and validates
// the resulting application configuration.
// LoadSequenceCatalog searches its caller-selected main path before a custom
// fallback directory, assembles imported fragments over a file-backed underlay,
// applies one selected collection overlay, then resolves and validates once.
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
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))
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
mainSearch := configkit.SearchConfig("", configkit.Searches(
configkit.Paths(absoluteMain),
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
}),
), configkit.SearchOptions{Required: true})
definition := configkit.Definition[SequenceCatalogConfig]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
@@ -51,8 +61,9 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
Validate: validateSequenceCatalog,
}
return configkit.LoadLayered(configkit.Input{
Source: filesystem.File(filepath.Base(mainPath)),
Imports: true,
Source: mainSearch,
Imports: true,
Filesystem: &configkit.FilesystemContext{Root: root},
}, configkit.LayeredOptions[SequenceCatalogConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: filesystem.File("underlay.yml"),

316
facade_discovery_test.go Normal file
View 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
}

View File

@@ -0,0 +1,255 @@
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)) {
resolved, diagnostic := validateFilesystemContext(selectedSource, request.input.filesystem, parsed, tier.hostToken)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
}
}
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
}

View File

@@ -5,6 +5,29 @@ import (
"testing"
)
func TestImportSchedulingKeepsUnselectedSlotsAndScopeBoundaries(t *testing.T) {
dependent := importNodeKey{resourcePath: "a-dependent"}
external := importNodeKey{resourcePath: "external"}
prerequisite := importNodeKey{resourcePath: "z-prerequisite"}
graph := newImportInclusionGraph()
for _, key := range []importNodeKey{dependent, external, prerequisite} {
graph.addNode(importGraphNode{key: key})
}
graph.addEdge(dependent, prerequisite, Location{})
graph.addEdge(dependent, external, Location{})
graph.addEdge(external, prerequisite, Location{})
contributions := []acquiredContribution{
{identity: dependent}, {identity: external}, {identity: prerequisite},
}
scheduled := scheduleContributionSlots(contributions, []int{0, 2}, indexImportDependencies(graph))
if scheduled[0].identity != prerequisite || scheduled[1].identity != external || scheduled[2].identity != dependent {
t.Fatalf("scope schedule = %#v", scheduled)
}
if contributions[0].identity != dependent || contributions[2].identity != prerequisite {
t.Fatal("scheduling changed the input slots")
}
}
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
names := []string{"a-base", "b-independent", "z-dependent"}
keys := make(map[string]importNodeKey, len(names))
@@ -33,7 +56,7 @@ func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.
}
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, graph)
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, indexImportDependencies(graph))
got := make([]string, len(scheduled))
for index, contribution := range scheduled {
got[index] = contribution.identity.resourcePath[1:]

View File

@@ -216,9 +216,8 @@ func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
currentLimits := limits
currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked(
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
)
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
@@ -279,9 +278,8 @@ func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
currentLimits := limits
currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked(
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
)
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
@@ -366,9 +364,8 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
main := parseImportMain(t, mainPath)
limits := defaultImportLimits()
accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked(
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
)
request := testImportRequest(File(mainPath), nil, main, destination, nil, nil)
composed, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
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},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
_, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
t.Fatalf("overlay diagnostics = %#v", diagnostics)
}
@@ -431,7 +428,7 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
overrides: []Override{override},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
input, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
input, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 0 {
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})
}

View File

@@ -108,10 +108,14 @@ parent: true
}
defer func() { _ = access.close() }()
state := importTraversal{
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
access: access,
policy: &acquisitionPolicy{importLimits: defaultImportLimits()},
state: &acquisitionState{accounting: &importAccounting{sources: 1}},
tier: &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)},
sources: sourceInfos(main.source),
active: make(map[resourceIdentity]int),
}
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry"), 0); len(diagnostics) != 0 {
t.Fatalf("walk diagnostics = %#v", diagnostics)
}
@@ -125,11 +129,11 @@ parent: true
{path: alphaPath, destination: "/entry/collection"},
{path: betaPath, destination: "/entry/collection"},
}
if len(state.sources) != len(wantSources) {
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
if len(state.sources) != len(wantSources)+1 {
t.Fatalf("sources = %#v, want %d including main", state.sources, len(wantSources)+1)
}
for index, want := range wantSources {
got := state.sources[index]
got := state.sources[index+1]
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
}
@@ -160,11 +164,10 @@ func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
t.Run("import context failure", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
input, diagnostics := assembleBaseInputTracked(
File(mainPath),
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
Keys("mounted"), &importAccounting{}, nil, nil,
)
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: t.TempDir()}, destination: Keys("mounted")},
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
t.Fatalf("context diagnostics = %#v", diagnostics)
}
@@ -177,10 +180,10 @@ func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
writeImportFile(t, directory, "broken.yml", "broken: [\n")
input, diagnostics := assembleBaseInputTracked(
File(mainPath), Options[map[string]any]{Imports: true},
Keys("mounted"), &importAccounting{}, nil, nil,
)
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true, destination: Keys("mounted")},
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}

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

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

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

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

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

View 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())
}
}
})
}
}

View File

@@ -3,40 +3,52 @@ package engine
import (
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
return diagnostic
}
return validateNativeDirectories(access, basePath, mainPath)
}
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
resolved, diagnostic := resolveFilesystemContext(source, context, main, hostToken)
if diagnostic != nil {
return diagnostic
// Keep absent-input validation's existing diagnostic while sharing
// root/base derivation with overlay and resource-context handling.
if source.kind == sourceNone && context.FS == nil && diagnostic.Code == CodeImportOutsideRoot {
diagnostic.Message = "import base must stay inside the configuration root"
}
return resolvedFilesystemContext{}, diagnostic
}
root, err := os.OpenRoot(rootPath)
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
return resolvedFilesystemContext{}, diagnostic
}
if resolved.native != nil {
return resolved, nil
}
root, err := os.OpenRoot(resolved.rootPath)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
value.Primary = nil
return &value
return resolvedFilesystemContext{}, &value
}
if err := root.Close(); err != nil {
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
return &value
return resolvedFilesystemContext{}, &value
}
return nil
return resolved, nil
}
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
switch source.kind {
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:
if context.FS != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
@@ -69,115 +81,19 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
return nil
}
func nativeContext(source Source, context *FilesystemContext) bool {
return source.kind == sourceFilesystem || context != nil && context.FS != nil
}
// 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
func absentFilesystemOptionDiagnostics(context *FilesystemContext) []Diagnostic {
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")}
}
} 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
}
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
if main.source == nil || main.document == 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 nativeContext(source Source, context *FilesystemContext) bool {
return source.kind == sourceFilesystem || context != nil && context.FS != nil
}
func canonicalExistingPath(name string) (string, error) {

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

View File

@@ -216,7 +216,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
main := parseImportMain(t, mainPath)
limits := defaultImportLimits()
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 {
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
}
@@ -228,7 +229,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
main = parseImportMain(t, mainPath)
limits = defaultImportLimits()
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 {
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 {
t.Helper()
requiredLine := ""

View File

@@ -0,0 +1,531 @@
package engine
import (
"bytes"
"errors"
"io/fs"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
type importAdmissionOutcome uint8
const (
importAdmissionReady importAdmissionOutcome = iota
importAdmissionOptionalMissing
)
type admittedImport struct {
canonical string
identity resourceIdentity
nodeKey importNodeKey
source SourceInfo
sourceIndex int
payload *document.Node
declarations []importDeclarationSpec
sequenceDeclarations []sequenceDeclarationSpec
processing *sourceVersionProcessingResult
}
type importAdmissionResult struct {
outcome importAdmissionOutcome
source admittedImport
}
func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (*composedDocument, []SourceInfo, *resolvedFilesystemContext, []Diagnostic) {
source := request.input.input.source
context := request.input.input.filesystem
main := request.main
resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
if diagnostic != nil {
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
}
if resolved.native == nil {
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
}
}
access, err := openResolvedFilesystemAccess(resolved)
if err != nil {
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
diagnostic.Primary = nil
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
}
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
if len(diagnostics) != 0 {
return composition, sources, nil, diagnostics
}
return composition, sources, &resolved, nil
}
func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) {
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{}, 0)
} else {
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
placementNodes := destinationWrapperNodeCount(destination)
if err != nil {
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} else if accounting.nodes+placementNodes > limits.nodes {
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
} else {
accounting.nodes += placementNodes
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
destination: destination.clone(), processing: processing,
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
})
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
}
}
}
if len(diagnostics) == 0 {
scheduleAcquisitionScopes(&state.scope, indexImportDependencies(state.importGraph))
}
closeErr := 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, sourceIndex int) []Diagnostic {
policies := make([]resolvedImportPolicy, len(declarations))
for index, spec := range declarations {
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
if len(diagnostics) != 0 {
return diagnostics
}
if policy.placement == placementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
site := spec.fieldSites["placement"]
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
}
policies[index] = policy
}
for index, spec := range declarations {
policy := policies[index]
var selection importSelection
var diagnostics []Diagnostic
switch spec.selector {
case importSelectorPath:
selection, diagnostics = selectNamedImportCandidates(s.access, basePath, spec, policy)
case importSelectorDirectory:
selection, diagnostics = selectDirectoryImportCandidates(s.access, basePath, spec, policy, s.policy.importLimits, s.state.accounting)
case importSelectorGlob:
selection, diagnostics = selectGlobImportCandidates(s.access, basePath, spec, policy, s.policy.importLimits, s.state.accounting)
default:
location := spec.location
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import selector is invalid", &location)}
}
if len(diagnostics) != 0 {
return diagnostics
}
childDestination := appendDestination(parentDestination, policy.destination)
// Count admitted records rather than lexical candidates: optional missing
// paths are omitted during admission, and a partially present list is not empty.
sourcesBefore := len(s.sources)
for _, candidate := range selection.candidates {
if diagnostics := s.visitPath(candidate, spec, policy, childDestination, policy.destination, depth+1); len(diagnostics) != 0 {
return diagnostics
}
}
if !policy.required && len(s.sources) == sourcesBefore {
s.recordEmptyImportSelection(sourceIndex, spec)
}
}
return nil
}
func (s *importTraversal) recordEmptyImportSelection(sourceIndex int, spec importDeclarationSpec) {
// The declaring source's position survives descendant appends and optional
// missing-source rollback, unlike a pointer into the source slice.
s.sources[sourceIndex].importSelections = append(s.sources[sourceIndex].importSelections, importSelectionEvidence{
path: spec.path.clone(), location: spec.location,
selector: spec.selector, outcome: importSelectionNone,
})
}
func (s *importTraversal) admitImportSource(canonical string, identity resourceIdentity, nodeKey importNodeKey, candidate selectedImportCandidate, policy resolvedImportPolicy, destination Path, location Location) (importAdmissionResult, []Diagnostic) {
sourceID := "source-" + strconv.Itoa(s.state.accounting.sources+1)
kind := SourceKindFile
label := candidate.label
if label == "" {
label = candidate.path
}
if s.access.native != nil {
kind = SourceKindFilesystem
label = s.access.label + "/" + canonical
}
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
if policy.placement == placementModeSequenceItem {
info.placementMode = placementModeSequenceItem
}
// A source's own item placement still has an exact sequence-container
// destination. Only an unresolved enclosing item index makes the path a
// fallback container for this snapshot.
info.destinationUnresolved = s.itemScope
if kind == SourceKindFilesystem {
info.namespace = s.access.token
}
sourceIndex := len(s.sources)
s.sources = append(s.sources, info)
data, skipped, diagnostic := s.readImport(canonical, sourceID, policy.required)
if diagnostic != nil {
return importAdmissionResult{}, []Diagnostic{*diagnostic}
}
if skipped {
s.sources = s.sources[:len(s.sources)-1]
return importAdmissionResult{outcome: importAdmissionOptionalMissing}, nil
}
s.state.accounting.sources++
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
})
if len(issues) != 0 {
return importAdmissionResult{}, parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(root)
if s.state.accounting.nodes+nodeCount > s.policy.importLimits.nodes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)
return importAdmissionResult{}, []Diagnostic{value}
}
s.state.accounting.nodes += nodeCount
directiveContext := s.request.input.directiveContext
if directiveContext == 0 {
directiveContext = rootDirectiveContextImportsOnly
}
versionDestination := destination
if s.itemScope {
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
}
var processing sourceVersionProcessingResult
if s.state.versionState != nil && s.state.versionState.mode != sourceVersionModeDefault {
processed, failures := s.state.versionState.processWithDirectiveContext(root, sourceVersionContribution, versionDestination, sourceID, policy.placement, directiveContext)
if len(failures) != 0 {
return importAdmissionResult{}, failures
}
processing = processed
} else {
processed := processSourceVersionWithDirectiveContext(root, s.request.input.protectionRule, sourceVersionContribution, versionDestination, sourceVersionModeDefault, directiveContext)
if len(processed.diagnostics) != 0 {
return importAdmissionResult{}, processed.diagnostics
}
processing = processed
}
return importAdmissionResult{source: admittedImport{
canonical: canonical, identity: identity, nodeKey: nodeKey,
source: cloneSourceInfo(info), sourceIndex: sourceIndex, payload: processing.payload,
declarations: processing.imports,
sequenceDeclarations: cloneSequenceDeclarations(processing.sequences),
processing: &processing,
}}, nil
}
func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec importDeclarationSpec, policy resolvedImportPolicy, destination, relativeDestination Path, depth int) []Diagnostic {
location := candidate.site.location
if location.SourceID == "" && location.Line == 0 && location.Column == 0 {
location = spec.location
}
if !s.access.contains(candidate.accessPath) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
}
canonical, err := s.access.canonical(candidate.accessPath)
if err != nil {
if !policy.required && errors.Is(err, fs.ErrNotExist) {
return nil
}
code := CodeImportInvalid
message := "import target could not be canonicalized"
if errors.Is(err, fs.ErrNotExist) {
code, message = CodeImportMissing, "required import target does not exist"
} else if errors.Is(err, fs.ErrPermission) {
message = "import target cannot be accessed"
}
return []Diagnostic{importDiagnostic(code, message, &location)}
}
if !s.access.contains(canonical) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
}
if depth > s.policy.importLimits.depth {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
}
currentRoute := s.currentRoute(location)
identity := s.access.identity(canonical)
nodeKey := importKeyFor(s.access, canonical, false)
importerKey := s.currentImportNode()
if activeIndex, ok := s.active[identity]; ok {
closingEdge := s.importGraph.addEdge(importerKey, nodeKey, location)
if diagnostic, ok := s.importGraph.scheduleCycle(closingEdge, location); ok {
return []Diagnostic{diagnostic}
}
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
}
if previous, ok := s.tier.visited[identity]; ok {
s.importGraph.addEdge(importerKey, nodeKey, location)
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
}
if s.state.accounting.sources+1 > s.policy.importLimits.sources {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
}
if _, err := s.access.relative(canonical); err != nil {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
}
admission, diagnostics := s.admitImportSource(canonical, identity, nodeKey, candidate, policy, destination, location)
if len(diagnostics) != 0 {
return diagnostics
}
if admission.outcome == importAdmissionOptionalMissing {
return nil
}
admitted := admission.source
placementDestination := destination
mode := placementModeMapping
if policy.placement == placementModeSequenceItem {
mode = placementModeSequenceItem
}
role := importRoleMapping
if mode == placementModeSequenceItem {
role = importRoleItem
}
s.importGraph.addNode(importGraphNode{key: admitted.nodeKey, canonical: admitted.canonical, sourceID: admitted.source.ID, role: role})
s.importGraph.addEdge(importerKey, admitted.nodeKey, location)
if mode == placementModeSequenceItem {
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
s.active[admitted.identity] = len(s.stack)
s.tier.visited[admitted.identity] = route
s.stack = append(s.stack, route)
diagnostics := s.visitItemSource(admitted, destination, relativeDestination, depth)
delete(s.active, admitted.identity)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
_, placed, err := placeDocument(admitted.payload, placementDestination, admitted.payload.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
}
placementNodes := destinationWrapperNodeCount(placementDestination)
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.state.accounting.nodes += placementNodes
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
source: cloneSourceInfo(admitted.source), payload: admitted.payload, document: placed,
identity: admitted.nodeKey, role: role, scopeOwner: s.scope.owner,
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: admitted.processing,
sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
})
s.active[admitted.identity] = len(s.stack)
s.tier.visited[admitted.identity] = route
s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, destination, admitted.sourceIndex)
delete(s.active, admitted.identity)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
func (s *importTraversal) currentImportNode() importNodeKey {
if len(s.stack) != 0 {
return s.stack[len(s.stack)-1].key
}
key := s.scope.owner
if _, exists := s.importGraph.index[key]; !exists {
s.importGraph.addNode(importGraphNode{key: key, canonical: "<selected-source>", role: importRoleMain})
}
return key
}
func (s *importTraversal) sourceDestination(destination Path) Path {
if !s.itemScope {
return destination.clone()
}
// Until the enclosing item receives its assigned index, only the collection
// container is exact. Keep the local suffix in the acquisition projection.
return s.itemFallback.clone()
}
func (s *importTraversal) readImport(name, sourceID string, required bool) ([]byte, bool, *Diagnostic) {
remaining := s.policy.importLimits.bytes - s.state.accounting.bytes
limit := min(int64(maxSourceBytes), remaining)
if limit < 0 {
limit = 0
}
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
if failure != nil {
if !required && failure.missingTarget {
return nil, true, nil
}
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
return nil, false, &value
}
s.state.accounting.bytes += int64(len(data))
return data, false, nil
}
func (s *importTraversal) currentRoute(location Location) importRoute {
locations := make([]Location, 0, len(s.stack)+1)
for _, route := range s.stack {
if len(route.locations) != 0 {
locations = append(locations, route.locations[len(route.locations)-1])
}
}
locations = append(locations, location)
return importRoute{locations: locations}
}

View File

@@ -2,11 +2,54 @@ package engine
import (
"errors"
"fmt"
"path/filepath"
"reflect"
"testing"
)
func TestBroadEmptyImportSelectionsKeepDeclaringSourceOwnership(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: fragments/*.yml
- path: []
`)
const fragments = 32
for index := 0; index < fragments; index++ {
writeImportFile(t, directory, fmt.Sprintf("fragments/fragment-%02d.yml", index), `configkit:
imports:
- path: missing.yml
- path: []
`)
}
loaded, err := testLoad[map[string]any](File(mainPath), Options[map[string]any]{Imports: true})
if err != nil {
t.Fatal(err)
}
sources := loaded.Sources()
if len(sources) != fragments+1 {
t.Fatalf("source count = %d, want %d", len(sources), fragments+1)
}
for index, source := range sources {
wantPaths := []string{"/configkit/imports/0", "/configkit/imports/1"}
if index == 0 {
wantPaths = wantPaths[1:]
}
if len(source.importSelections) != len(wantPaths) {
t.Fatalf("source %s selections = %#v", source.ID, source.importSelections)
}
for selectionIndex, selection := range source.importSelections {
if selection.path.String() != wantPaths[selectionIndex] || selection.location.SourceID != source.ID ||
selection.selector != importSelectorPath || selection.outcome != importSelectionNone {
t.Errorf("source %s selection %d = %#v", source.ID, selectionIndex, selection)
}
}
}
}
func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) {
t.Parallel()

View File

@@ -157,7 +157,20 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
}, true
}
func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
// importDependencyIndex is built once per scheduling operation and shared by
// its scopes. Inclusion targets are grouped by importer, so local scheduling
// need not scan or reserve storage for unrelated graph edges.
type importDependencyIndex map[importNodeKey][]importNodeKey
func indexImportDependencies(graph importInclusionGraph) importDependencyIndex {
index := make(importDependencyIndex)
for _, edge := range graph.edges {
index[edge.importer] = append(index[edge.importer], edge.imported)
}
return index
}
func scheduleAcquisitionScope(scope acquisitionScope, dependencies importDependencyIndex) acquisitionScope {
slots := make([]int, 0, len(scope.contributions))
for index, contribution := range scope.contributions {
if contribution.scopeOwner != scope.owner {
@@ -166,7 +179,7 @@ func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph
slots = append(slots, index)
}
scheduled := scope
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, graph)
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, dependencies)
return scheduled
}
@@ -179,10 +192,10 @@ func scheduleAcquisitionGroup(scopes []acquisitionScope, graph importInclusionGr
for index := range slots {
slots[index] = index
}
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, graph)}
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, indexImportDependencies(graph))}
}
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, graph importInclusionGraph) []acquiredContribution {
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, dependencies importDependencyIndex) []acquiredContribution {
if len(slots) < 2 {
return append([]acquiredContribution(nil), contributions...)
}
@@ -193,12 +206,12 @@ func scheduleContributionSlots(contributions []acquiredContribution, slots []int
positions[contribution.identity] = position
keys[position] = contribution.identity.dependencyKey()
}
edges := make([]dependencyEdge, 0, len(graph.edges))
for _, edge := range graph.edges {
importer, importerOK := positions[edge.importer]
imported, importedOK := positions[edge.imported]
if importerOK && importedOK {
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
var edges []dependencyEdge
for importerKey, importer := range positions {
for _, importedKey := range dependencies[importerKey] {
if imported, exists := positions[importedKey]; exists {
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
}
}
}
order, _ := scheduleDependencies(keys, edges)

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

View File

@@ -0,0 +1,287 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func compositionTraversal(policy sequencePolicy, rule *VersionRule, versionState *sourceVersionState, accounting *importAccounting, graph importInclusionGraph) importTraversal {
return importTraversal{
request: importAcquisitionRequest{input: inputAcquisitionRequest{protectionRule: rule}},
policy: &acquisitionPolicy{sequencePolicy: policy, importLimits: defaultImportLimits()},
state: &acquisitionState{accounting: accounting, versionState: versionState},
tier: &tierAcquisitionState{},
importGraph: graph.clone(),
}
}
// composeAcquiredInputWithPolicy is the per-input adapter from retained
// acquisition scopes to the established strict-composition behavior. It uses
// the already acquired documents and graph; source handles are closed before
// this handoff.
func composeAcquiredInputWithPolicies(input assembledInput, lower collectionPolicyScope, policy sequencePolicy) (*composedDocument, []Diagnostic) {
if input.composition == nil || input.composition.root != nil || len(input.composition.acquisition) == 0 {
return input.composition, nil
}
acquired := input.composition
if len(acquired.acquisition) != 1 {
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "acquired input has an invalid composition scope", nil)}
}
scope := cloneAcquisitionScope(acquired.acquisition[0])
var rule *VersionRule
if input.versionState != nil {
rule = input.versionState.effectiveRule
}
accounting := input.accounting
if accounting == nil {
accounting = &importAccounting{sources: len(input.sources), bytes: acquired.inputBytes, nodes: acquired.inputNodes}
}
traversal := compositionTraversal(policy, rule, input.versionState, accounting, acquired.importGraph)
if len(scope.contributions) == 1 && scope.contributions[0].role == importRoleMain && scope.contributions[0].pendingItem != nil {
main := scope.contributions[0]
localScope := cloneAcquisitionScope(*main.pendingItem)
if diagnostics := traversal.composePendingItems(&localScope); len(diagnostics) != 0 {
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
}
documents := make([]*document.Node, 0, len(localScope.contributions))
for _, contribution := range localScope.contributions {
documents = append(documents, contribution.document)
}
completed, diagnostics := composeDocumentsWithCollectionPolicy(
policy, localScope.collectionPolicies,
acquiredSequenceDeclarations(localScope), documents...,
)
if len(diagnostics) != 0 {
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
}
if input.versionState != nil {
protectionRule := input.versionState.localRule
if input.versionState.mode == sourceVersionModeDefault && protectionRule != nil {
prefix := main.destination.Index(0)
relative, inside := relativeHistoryPath(prefix, protectionRule.Path)
if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) {
protectionRule = nil
} else {
scoped := *protectionRule
scoped.Path = relative
protectionRule = &scoped
}
}
if diagnostic := importedVersionClaim(documents, localScope, completed, protectionRule); diagnostic != nil {
if rule != nil {
diagnostic.Path = rule.Path.clone()
}
return nil, []Diagnostic{*diagnostic}
}
}
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
projectNestedEvidence(completed, localScope)
_, placed, err := placeDocumentWithMode(completed.root, main.destination, placementModeSequenceItem, main.payload.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return nil, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
}
if !main.placementCharged {
placementNodes := destinationWrapperNodeCountForMode(main.destination, placementModeSequenceItem)
if accounting.nodes+placementNodes > traversal.policy.importLimits.nodes {
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", main.source.ImportedFrom)}
}
accounting.nodes += placementNodes
}
composition, diagnostics := composeDocumentsWithPolicy(policy, placed)
if len(diagnostics) != 0 {
return nil, diagnostics
}
synthetic := acquisitionScope{owner: main.scopeOwner, contributions: []acquiredContribution{{
source: cloneSourceInfo(main.source), payload: completed.root, document: placed,
identity: main.identity, role: importRoleMain, scopeOwner: main.scopeOwner,
destination: main.destination.clone(), processing: main.processing, nested: completed,
}}}
projectNestedEvidence(composition, synthetic)
projectAcquiredDestinations(composition, synthetic, input.sources)
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
composition.inputBytes = accounting.bytes
composition.inputNodes = accounting.nodes
composition.importGraph = acquired.importGraph.clone()
return composition, nil
}
return composeAcquiredScopeWithPolicy(
&scope, lower, policy, acquired.importGraph, input.sources, rule, input.versionState, accounting,
)
}
// composeAcquiredScopeWithPolicy is shared by per-input and underlay-group
// handoffs. Lower policy is carried separately from registered application
// constraints; current strict composition continues to use its existing rules.
func composeAcquiredScopeWithPolicy(
scope *acquisitionScope,
lower collectionPolicyScope,
policy sequencePolicy,
graph importInclusionGraph,
sources []SourceInfo,
rule *VersionRule,
versionState *sourceVersionState,
accounting *importAccounting,
) (*composedDocument, []Diagnostic) {
traversal := compositionTraversal(policy, rule, versionState, accounting, graph)
if diagnostics := traversal.composePendingItems(scope); len(diagnostics) != 0 {
return nil, diagnostics
}
documents := make([]*document.Node, 0, len(scope.contributions))
for _, contribution := range scope.contributions {
documents = append(documents, contribution.document)
}
composition, diagnostics := composeDocumentsWithCollectionPolicy(
policy, lower, acquiredSequenceDeclarations(*scope), documents...,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
if diagnostic := importedVersionClaim(documents, *scope, composition, rule); diagnostic != nil {
return nil, []Diagnostic{*diagnostic}
}
projectNestedEvidence(composition, *scope)
projectAcquiredDestinations(composition, *scope, sources)
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(*scope)}
composition.inputBytes = accounting.bytes
composition.inputNodes = accounting.nodes
composition.importGraph = graph.clone()
return composition, nil
}
func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic {
if scope == nil {
return nil
}
for index := range scope.contributions {
contribution := &scope.contributions[index]
if contribution.pendingItem == nil {
continue
}
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem)
if accounting.nodes+placementNodes > limits.nodes {
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)
return &diagnostic
}
accounting.nodes += placementNodes
contribution.placementCharged = true
if diagnostic := reservePendingItemPlacements(contribution.pendingItem, limits, accounting); diagnostic != nil {
return diagnostic
}
}
return nil
}
func (s *importTraversal) visitItemSource(admitted admittedImport, destination, relativeDestination Path, depth int) []Diagnostic {
parentScope := s.scope
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
s.scope = acquisitionScope{owner: admitted.nodeKey}
s.itemScope = true
if parentItemScope {
s.itemPrefix = appendDestination(parentPrefix, destination)
} else {
s.itemPrefix = destination.clone()
s.itemFallback = destination.clone()
}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
source: cloneSourceInfo(admitted.source), payload: admitted.payload, document: admitted.payload,
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: admitted.nodeKey,
processing: admitted.processing, sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
})
diagnostics := s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, Path{}, admitted.sourceIndex)
itemScope := s.scope
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
if len(diagnostics) != 0 {
return diagnostics
}
itemScope.destination = Path{}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
source: cloneSourceInfo(admitted.source), payload: admitted.payload,
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: parentScope.owner,
destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: admitted.processing,
pendingItem: &itemScope,
})
return nil
}
func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic {
for index := range scope.contributions {
contribution := &scope.contributions[index]
if contribution.pendingItem == nil {
continue
}
itemScope := contribution.pendingItem
if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
}
documents := make([]*document.Node, 0, len(itemScope.contributions))
for _, nested := range itemScope.contributions {
documents = append(documents, nested.document)
}
// An item scope is below a sequence index. Application selectors match
// mapping-only effective paths, so none can apply inside this scope.
// Its YAML declarations remain relative to the owning record.
completed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, itemScope.collectionPolicies,
acquiredSequenceDeclarations(*itemScope), documents...,
)
if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
}
// Version ownership is checked by the enclosing composition once this
// item has its effective coordinates, not against its local field names.
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
projectNestedEvidence(completed, *itemScope)
_, placed, err := placeDocumentWithMode(completed.root, contribution.destination, placementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)}
}
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem)
if !contribution.placementCharged {
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)}
}
s.state.accounting.nodes += placementNodes
}
contribution.payload = completed.root
contribution.document = placed
contribution.nested = completed
contribution.pendingItem = nil
}
return nil
}
func acquiredSequenceDeclarations(scope acquisitionScope) []sequenceDeclarationSpec {
var declarations []sequenceDeclarationSpec
for _, contribution := range scope.contributions {
projected := cloneSequenceDeclarations(contribution.sequenceDeclarations)
for index := range projected {
projected[index].destination = appendDestination(contribution.relativeDestination, projected[index].destination)
}
declarations = append(declarations, projected...)
}
return declarations
}
func importedVersionClaim(documents []*document.Node, scope acquisitionScope, composed *composedDocument, rule *VersionRule) *Diagnostic {
if composed == nil || rule == nil {
return nil
}
mainIndex := -1
for index, contribution := range scope.contributions {
if contribution.role == importRoleMain {
mainIndex = index
break
}
}
ranges := indexSequenceRanges(composed.sequenceRanges)
for index, contribution := range documents {
if index == mainIndex {
continue
}
if node := fragmentVersionClaimWithIndex(contribution, rule, index, ranges); node != nil {
location := publicLocation(node.Location)
return &Diagnostic{
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
}
}
}
return nil
}

View File

@@ -139,72 +139,23 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
}
entry := frame.entries[frame.next]
frame.next++
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,
result, diagnostic := prepareImportDirectoryEntry(access, basePath, *frame, entry, policy, limits, accounting, activeDirectories)
if diagnostic != nil {
return importSelection{}, []Diagnostic{*diagnostic}
}
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
switch result.kind {
case importDirectoryEntrySkip:
continue
}
target, entryErr := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
if entryErr != nil {
if errors.Is(entryErr, fs.ErrNotExist) {
case importDirectoryEntryFile:
if _, exists := seenCandidates[result.candidate.path]; exists {
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 })
@@ -229,6 +180,73 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
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 {
directory selectedImportCandidate
depth int
@@ -237,6 +255,20 @@ type importDirectoryFrame struct {
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 {
if isImportLinkCycle(err) {
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")

View File

@@ -251,40 +251,17 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
}
switch entry.Key {
case "required", "recursive", "follow", "hidden":
if entry.Value.Kind != document.Boolean {
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, entry.Key+" must be a boolean", entry.Value)}
}
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
if diagnostic := parseImportBooleanAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
}
case "pattern":
values, origins, ok, badIndex := parseStringSet(entry.Value)
if !ok {
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "pattern", badIndex)}
if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
}
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":
values, origins, ok, badIndex := parseStringSet(entry.Value)
if !ok {
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "exclude", badIndex)}
if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
}
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":
depth, ok := parseImportDepth(entry.Value)
if !ok {
@@ -298,17 +275,11 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
}
spec.destination = destination
case "placement":
if entry.Value.Kind != document.String {
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
}
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)}
placement, diagnostic := parseImportPlacement(entry.Value, fieldPath)
if diagnostic != nil {
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
}
spec.placement = placement
default:
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)
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 {
case importSelectorPath, importSelectorDirectory:
values, origins, ok, badIndex := parseStringSet(selectorEntry.Value)
values, origins, ok, badIndex := parseStringSet(node)
if !ok {
name := selectorEntry.Key
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(selectorEntry.Value, selectorPath, name, badIndex)}
diagnostic := stringSetDiagnostic(node, selectorPath, field, badIndex)
return &diagnostic
}
for index, value := range values {
if !validImportPath(value) {
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)}
badPath, badNode := listMemberSite(node, selectorPath, origins[index])
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.paths = values
case importSelectorGlob:
if selectorEntry.Value.Kind != document.String || !validImportPath(selectorEntry.Value.Text) {
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", selectorEntry.Value)}
if node.Kind != document.String || !validImportPath(node.Text) {
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 {
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 {

File diff suppressed because it is too large Load Diff

View File

@@ -113,10 +113,13 @@ tasks: [{id: z-parent}]
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
Imports: true,
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
})
importLimits: defaultImportLimits(),
}, nil)
if len(diagnostics) != 0 {
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-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 {
t.Fatalf("source contexts: %#v", diagnostic)
}
@@ -398,7 +406,7 @@ label: helper
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
loaded, err := LoadLayered(
Input{Source: NoSource()}, testSecretBinding(),
Input{Source: NoSource(), Imports: true}, testSecretBinding(),
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
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")
accounting := &importAccounting{}
input, diagnostics := assembleBaseInputTracked(File(mainPath), Options[struct{}]{
Imports: true, Filesystem: &FilesystemContext{Root: directory}, deferAssembly: true,
}, Path{}, accounting, nil, nil)
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: directory}},
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly, deferAssembly: true,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, accounting)
if len(diagnostics) != 0 {
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, "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 {
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")
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 {
t.Fatalf("assemble input: %#v", diagnostics)
}
@@ -1092,6 +1107,70 @@ id: item
})
}
func TestBroadItemScopesKeepHelperOriginsAndDestinations(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
directory := t.TempDir()
const count = 32
var main strings.Builder
main.WriteString("configkit:\n imports:\n")
for index := count - 1; index >= 0; index-- {
name := strconv.Itoa(100 + index)
main.WriteString(" - path: item-" + name + ".yml\n destination: [items]\n placement: sequence-item\n")
writeImportFile(t, directory, "item-"+name+".yml", "configkit: {imports: [{path: helper-"+name+".yml}]}\nid: item-"+name+"\n")
writeImportFile(t, directory, "helper-"+name+".yml", "value: helper-"+name+"\n")
}
mainPath := writeImportFile(t, directory, "main.yml", main.String())
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
}}}
for _, layered := range []bool{false, true} {
t.Run(strconv.FormatBool(layered), func(t *testing.T) {
var loaded *Loaded[config]
var err error
if layered {
loaded, err = LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[config]{
Definition: definition,
Underlays: []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}},
})
} else {
loaded, err = Load(File(mainPath), testSecretBinding(), Options[config]{Imports: true, Definition: definition})
}
if err != nil {
t.Fatal(err)
}
result := testLoaded(loaded)
sources := result.Sources()
if len(result.Config.Items) != count || len(sources) != 2*count+1 {
t.Fatalf("item/source counts = %d/%d", len(result.Config.Items), len(sources))
}
metadata := result.Metadata()
for index, record := range result.Config.Items {
name := strconv.Itoa(100 + index)
if record.ID != "item-"+name || record.Value != "helper-"+name {
t.Fatalf("item %d = %#v", index, record)
}
// Acquisition remains authored (descending); final item order is
// dependency-ready (ascending), with helper evidence reprojected.
helper := sources[2*(count-index)]
field, found := metadata.Lookup(Keys("items").Index(index).Key("value"))
if !found || len(field.Origin.Locations) != 1 || field.Origin.Locations[0].SourceID != helper.ID ||
helper.Path != canonicalTestPath(t, filepath.Join(directory, "helper-"+name+".yml")) ||
helper.Destination.String() != Keys("items").Index(index).String() {
t.Fatalf("item %d helper evidence = %#v, %#v", index, field, helper)
}
}
})
}
}
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
@@ -1104,7 +1183,10 @@ func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
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, "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 {
t.Fatalf("assemble item scope: %#v", diagnostics)
}
@@ -1268,7 +1350,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
} {
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 {
t.Fatalf("budget diagnostics = %#v", diagnostics)
}
@@ -1278,7 +1361,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
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 {
t.Fatalf("depth diagnostics = %#v", diagnostics)
}
@@ -1312,7 +1396,9 @@ func TestImportDepthBudgetCountsSelectedEdges(t *testing.T) {
writeImportFile(t, directory, "parts/next.yml", "next: true\n")
limits := defaultImportLimits()
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 len(diagnostics) != 1 || diagnostics[0].Code != test.code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
@@ -1342,7 +1428,8 @@ func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
bytes: main.bytes + int64(len(data)),
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 {
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
}
@@ -1401,6 +1488,42 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
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 {
t.Helper()
declarations, diagnostics := parseImportSpecificationYAML(input)

View File

@@ -228,17 +228,17 @@ func applyInheritanceSequencePolicy(
}
func applyInheritanceSequenceOrigins(
result *inheritanceScope,
origins, incomingOrigins map[string]Origin,
path Path,
lower, higher *document.Node,
combined sequenceCombinationResult,
operation sequenceMode,
) {
) []inheritanceReplacement {
var replacements []inheritanceReplacement
if operation == sequenceModeReplace {
if lower != nil {
displaced := displacedOrigins(lower, origins, path)
result.replacements = append(result.replacements, inheritanceReplacement{
replacements = append(replacements, inheritanceReplacement{
path: path.clone(), before: lower, after: higher,
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))
}
}
return
return replacements
}
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
@@ -268,7 +268,7 @@ func applyInheritanceSequenceOrigins(
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
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],
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))
}
}
return replacements
}
func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic {

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

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

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

View File

@@ -188,193 +188,52 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
for _, index := range order {
node := templates[index]
templatePolicies := cloneCollectionPolicyScopeForInheritance(node.collectionPolicies)
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()],
})
}
child := prepareInheritanceChild(node, inputOrigins)
if limits.nodes >= 0 {
remaining := limits.nodes - retained - adapter.slotOverhead()
within := inheritanceLowerBoundWithin(payload, remaining)
within := inheritanceLowerBoundWithin(child.payload, remaining)
if node.hasParent {
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], payload, remaining)
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], child.payload, remaining)
}
if !within {
return nil, []Diagnostic{inheritanceBudgetDiagnostic(node.path, node.entry.Value.Location)}
}
}
var parent *preparedInheritanceParent
var parentOrigins map[string]Origin
if node.hasParent {
parent := resolved[node.parent]
parentPolicies := cloneInheritancePolicyTemplates(resolvedPolicyTemplates[node.parent])
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
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,
prepared := prepareInheritanceParent(
node, templates[node.parent].path, resolved[node.parent], resolvedPolicyTemplates[node.parent],
resolvedPolicies[node.parent], child, adapter.applicationPolicy(),
)
if len(policyDiagnostics) != 0 {
return nil, policyDiagnostics
}
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)
parent = &prepared
parentOrigins = resolvedOrigins[node.parent]
}
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)
retained += adapter.slotOverhead() + count
for _, operation := range result.operations[operationStart:] {
if !counter.add(operation.parent) || !counter.add(operation.effective) {
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)}
}
if diagnostic := chargeInheritanceTemplateHistory(counter, templateResult, node); diagnostic != nil {
return nil, []Diagnostic{*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 nil, []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)}
if diagnostics := finalizeInheritanceScope(adapter, result, templates, resolved, resolvedOrigins, resolvedPolicies, inputOrigins, limits); len(diagnostics) != 0 {
return nil, diagnostics
}
return result, nil
}

View File

@@ -92,7 +92,7 @@ func TestPendingItemCompositionProjectsNestedIdentityDiagnostics(t *testing.T) {
destination: Keys("items"), pendingItem: &itemScope,
}}}
traversal := importTraversal{accounting: &importAccounting{}, limits: defaultImportLimits()}
traversal := compositionTraversal(sequencePolicy{}, nil, nil, &importAccounting{}, newImportInclusionGraph())
diagnostics := traversal.composePendingItems(&outer)
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)

View File

@@ -21,25 +21,25 @@ type Definition[T any] struct {
}
// Options contains the application definition and per-load input selections.
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
// Overrides contains only values explicitly supplied by an application CLI.
// Imports enables traversal of declarations in a selected main document; an
// 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 {
Definition Definition[T]
Imports bool
Overlay string
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode sourceVersionMode
importDefaults importDefaults
destination Path
placementMode placementMode
sequencePolicy sequencePolicy
deferAssembly bool
directiveContext rootDirectiveContext
importDefaultsValidated bool
Definition Definition[T]
Imports bool
Overlay string
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode sourceVersionMode
importDefaults importDefaults
destination Path
placementMode placementMode
}
// 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 {
BaseDir 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) {
options, diagnostics := selectOptions(source, supplied)
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
diagnostics = append(diagnostics, policyDiagnostics...)
if len(supplied) > 1 {
return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}}).loadError()
}
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 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
definition := options.Definition
definition.sequenceIdentityRegistrations = nil
request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,
main: input,
definition: definition, importDefaults: defaults, sequencePolicy: policy, overlay: options.Overlay,
overrides: options.Overrides, ordinary: true,
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)
}
@@ -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) {
accumulator := diagnosticAccumulator{}
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}
mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults)
mainOptions.sequencePolicy = request.sequencePolicy
mainOptions.deferAssembly = true
mainOptions.SourceVersionMode = request.sourceVersionMode
mainOptions.Definition.Version = request.protectionVersion()
if request.ordinary {
mainOptions.Overlay = request.overlay
mainOptions.directiveContext = rootDirectiveContextSource
} else {
mainOptions.directiveContext = rootDirectiveContextLayered
policy := acquisitionPolicy{
importDefaults: snapshotImportDefaults(request.importDefaults),
sequencePolicy: request.sequencePolicy,
importLimits: defaultImportLimits(),
}
var schema *schemaDescriptor
@@ -194,10 +198,21 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
}
versionState := &sourceVersionState{
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
importDefaults: snapshotImportDefaults(request.importDefaults),
}
state := &acquisitionState{accounting: accounting, 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.add(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) {
input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy)
input.composition, diagnostics = compositionForInputWithPolicy(input, policy.sequencePolicy)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
@@ -230,10 +245,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
}
if request.ordinary {
versionState.locateMainVersion(input.document)
mainOptions.Definition.Version = versionState.effectiveRule
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
input, diagnostics = applyInputLayers(request.main.source, request.main.filesystem, request.overlay, versionState.effectiveRule, policy.sequencePolicy, input)
} else {
input, diagnostics = assembleLayeredInput(request, input, accounting)
input, diagnostics = assembleLayeredInput(request, input, policy, state)
}
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
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 {
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 {
accumulator.add(*diagnostic)
return nil, accumulator.loadError()
@@ -265,7 +287,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
return nil, accumulator.loadError()
}
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 {
accumulator.add(problems...)
@@ -337,7 +359,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
if hasErrors(secretResolution.diagnostics) {
return nil, accumulator.loadError()
}
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, request.sequencePolicy)
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, policy.sequencePolicy)
accumulator.add(identityDiagnostics...)
if hasErrors(identityDiagnostics) {
return nil, accumulator.loadError()

View File

@@ -1,22 +1,22 @@
package engine
import (
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// assembledInput is the source/document handoff across schema and version checks.
type assembledInput struct {
parsed parsedSource
document *document.Node
composition *composedDocument
acquisition []acquisitionScope
inheritance *inheritanceScope
sources []SourceInfo
contexts map[string]ResourceContext
versionState *sourceVersionState
accounting *importAccounting
source Source
parsed parsedSource
document *document.Node
composition *composedDocument
acquisition []acquisitionScope
inheritance *inheritanceScope
sources []SourceInfo
contexts map[string]ResourceContext
versionState *sourceVersionState
accounting *importAccounting
resolvedFilesystem *resolvedFilesystemContext
}
// acquiredContribution keeps a source-local payload beside its placed document.
@@ -88,227 +88,42 @@ func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
return cloned
}
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil)
}
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) {
func applyInputLayers(source Source, filesystem *FilesystemContext, overlay string, protectionRule *VersionRule, policy sequencePolicy, input assembledInput) (assembledInput, []Diagnostic) {
if !activeOverlay(overlay) {
return input, nil
}
composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
composition, diagnostics := compositionForInputWithPolicy(input, policy)
if len(diagnostics) != 0 {
return input, diagnostics
}
assembly := newAssemblyAccumulator(composition)
if activeOverlay(options.Overlay) {
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
source, options.Filesystem, input.parsed, assembly, input.sources,
options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState, options.sequencePolicy,
)
if len(diagnostics) != 0 {
return input, diagnostics
}
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, input.parsed.source, input.sources[len(input.sources)-1:])
if diagnostic != nil {
resolved, diagnostic := resolveOrdinaryOverlayFilesystem(source, filesystem, input.parsed, input.resolvedFilesystem)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
if source.kind != sourceNone || nativeContext(source, filesystem) {
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
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.document = assembly.current.root
return input, nil

View File

@@ -49,35 +49,23 @@ type acquiredUnderlayGroup struct {
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
request.ordinary = false
var diagnostics []Diagnostic
request.importDefaults = snapshotImportDefaults(request.importDefaults)
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations)
defaults, policy, diagnostics := loadSettings(request.importDefaults, request.definition, request.sourceVersionMode)
request.importDefaults = defaults
request.sequencePolicy = policy
diagnostics = append(diagnostics, policyDiagnostics...)
request.definition.sequenceIdentityRegistrations = nil
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
importDefaults: request.importDefaults, importDefaultsValidated: true,
}})
request.main.destination = mainOptions.destination
main, mainDiagnostics := normalizeInput(request.main, "")
request.main = main
diagnostics = append(diagnostics, mainDiagnostics...)
request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
for groupIndex := range request.underlays {
for inputIndex := range request.underlays[groupIndex].inputs {
input := &request.underlays[groupIndex].inputs[inputIndex]
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{
Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode,
importDefaults: request.importDefaults, importDefaultsValidated: true,
}})
input.destination = inputOptions.destination
normalized, inputDiagnostics := normalizeInput(*input, "")
*input = normalized
diagnostics = append(diagnostics, inputDiagnostics...)
}
}
if !validOverlayName(request.overlay) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
}
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
if activeOverlay(request.overlay) {
if request.overlayFilesystem == nil {
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)
}
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
mainSourceCount := len(input.sources)
mainEvidence := input.versionState.evidenceSince(0)
projectVersion := sequenceVersionPath(request.protectionVersion())
for index, group := range request.underlays {
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)
if len(diagnostics) != 0 {
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.
protectionRule = nil
}
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, request.sequencePolicy, lower)
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, policy.sequencePolicy, lower)
sourceStart := len(input.sources)
input.sources = append(input.sources, acquired.sources...)
if len(diagnostics) != 0 {
@@ -130,7 +118,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
composition.root, composition.origins, composition.collectionPolicies, composition.applications, LayerUnderlay,
request.sequencePolicy, declarations,
policy.sequencePolicy, declarations,
)
if len(diagnostics) != 0 {
return input, diagnostics
@@ -148,7 +136,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
lower = assembly.current.collectionPolicies
}
var diagnostics []Diagnostic
mainComposition, diagnostics = compositionForInputWithPolicies(input, lower, request.sequencePolicy)
mainComposition, diagnostics = compositionForInputWithPolicies(input, lower, policy.sequencePolicy)
if len(diagnostics) != 0 {
return input, diagnostics
}
@@ -177,7 +165,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
mainComposition.root, mainComposition.origins, mainComposition.collectionPolicies, mainComposition.applications, LayerBase,
request.sequencePolicy, declarations,
policy.sequencePolicy, declarations,
)
if len(diagnostics) != 0 {
return input, append(versionDiagnostics, diagnostics...)
@@ -201,20 +189,33 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
if assembly == nil {
return input, versionDiagnostics
}
assembly.current.inputBytes = accounting.bytes
assembly.current.inputNodes = accounting.nodes
assembly.current.inputBytes = state.accounting.bytes
assembly.current.inputNodes = state.accounting.nodes
if activeOverlay(request.overlay) {
// The selected overlay context is independent of the main input. This
// synthetic descriptor creates no source record or input contribution.
selection := Source{kind: sourceBytes}
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
if request.overlayFilesystem == nil {
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
}
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
overlayPolicy := []sequencePolicy{request.sequencePolicy}
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState, overlayPolicy...)
access, diagnostic := openSelectedOverlayAccess(resolved)
if diagnostic != nil {
return input, append(versionDiagnostics, *diagnostic)
}
input.sources, diagnostics = loadSelectedOverlay(overlayRequest, access, assembly, input.sources)
if len(diagnostics) != 0 {
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 {
return input, append(versionDiagnostics, *diagnostic)
}
@@ -225,10 +226,9 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
return input, versionDiagnostics
}
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState, policy sequencePolicy) (*acquiredUnderlayGroup, []Diagnostic) {
initialBytes, initialNodes := accounting.bytes, accounting.nodes
visited := make(map[resourceIdentity]importRoute)
hostToken := &namespaceToken{}
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) {
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
nativeAnchors := make(map[*namespaceToken]string)
for inputIndex, contribution := range group.inputs {
if token := nativeTokenForLoadInput(contribution); token != nil {
@@ -240,19 +240,10 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, a
acquiredGroup := &acquiredUnderlayGroup{index: index}
inclusionGraph := newImportInclusionGraph()
for inputIndex, contribution := range group.inputs {
options := Options[struct{}]{
Imports: contribution.imports, Filesystem: contribution.filesystem,
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
placementMode: contribution.placementMode, sequencePolicy: policy, deferAssembly: true,
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)
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
input: contribution, role: sourceVersionContribution,
directiveContext: rootDirectiveContextLayered, protectionRule: rule, deferAssembly: true,
}, policy, state, tier)
for _, source := range acquired.sources {
source.Layer = LayerUnderlay
source.UnderlayIndex = index
@@ -286,8 +277,8 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, a
mergeImportInclusionGraph(&inclusionGraph, inputGraph)
}
acquiredGroup.importGraph = inclusionGraph.clone()
acquiredGroup.inputBytes = accounting.bytes - initialBytes
acquiredGroup.inputNodes = accounting.nodes - initialNodes
acquiredGroup.inputBytes = state.accounting.bytes - initialBytes
acquiredGroup.inputNodes = state.accounting.nodes - initialNodes
return acquiredGroup, nil
}

View File

@@ -80,7 +80,8 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
})
accounting := &importAccounting{}
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 {
t.Fatalf("acquire underlay group: %#v", diagnostics)
}
@@ -108,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
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 {
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) {
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 {
return nil, acquired.sources, acquired.contexts, diagnostics
}

View File

@@ -182,7 +182,12 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
t.Fatal(diagnostics)
}
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" {
t.Fatalf("contained imports = (%#v, %#v, %#v)", composed, sources, diagnostics)
}
@@ -196,7 +201,12 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
if len(diagnostics) != 0 {
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 {
t.Fatalf("escaping import diagnostics = %#v", diagnostics)
}

View File

@@ -1,45 +1,52 @@
package engine
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []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)
func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagnostic) {
var diagnostics []Diagnostic
if !options.importDefaultsValidated {
diagnostics = validateImportDefaults(options.importDefaults)
}
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
if input.placementMode != placementModeMapping && input.placementMode != placementModeSequenceItem {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
}
if normalized, err := normalizeDestination(options.destination); err != nil {
diagnostics = append(diagnostics, invalidDestinationDiagnostic(options.destination))
if normalized, err := normalizeDestination(input.destination); err != nil {
diagnostics = append(diagnostics, invalidDestinationDiagnostic(input.destination))
} 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"))
}
definition := options.Definition
if !validOverlayName(options.Overlay) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
if input.filesystem != nil {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(input.source, input.filesystem)...)
}
if options.Filesystem != nil {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
if input.source.kind == sourceSearch {
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 {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
}
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
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)) {
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, "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.Path.valid() {
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"))
}
}
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"))
} 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"))
}
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 {

View File

@@ -129,35 +129,42 @@ func TestPostDecodeOverrideLifecycleMatchesOrdinaryAndLayeredLoads(t *testing.T)
func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
t.Parallel()
type config struct {
Count int `yaml:"count"`
Derived int `yaml:"derived"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Derived int `yaml:"derived"`
}
calls := 0
result, err := testLoad(NoSource(), Options[config]{
Definition: Definition[config]{
Defaults: func() config {
calls++
return config{Count: 2}
return config{Count: 2, Enabled: true}
},
Resolve: func(value *config, resolution *Resolution) error {
info, ok := resolution.InputInfo(Keys("count"))
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
t.Errorf("resolution count info = %#v, %v", info, ok)
for _, key := range []string{"count", "enabled"} {
info, ok := resolution.InputInfo(Keys(key))
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("enabled"), &value.Enabled, true, "preserve CLI value")
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
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 {
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)
}
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)
}
}

View File

@@ -39,89 +39,9 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
switch descriptor.kind {
case descriptorStruct:
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
return o.projectStruct(value, descriptor, path, depth)
case descriptorMap:
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
return o.projectMap(value, descriptor, path, depth)
case descriptorSlice:
if value.IsNil() {
return o.node(document.Null, path, depth)
@@ -131,22 +51,9 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
return nil, issue
}
defer leave()
fallthrough
return o.projectSequence(value, descriptor, path, depth)
case descriptorArray:
result, issue := o.node(document.Sequence, 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
return o.projectSequence(value, descriptor, path, depth)
case descriptorDynamic:
if value.IsNil() {
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) {
if issue := o.checkDepth(path, depth); issue != nil {
return nil, issue

View File

@@ -237,22 +237,6 @@ func SourceInfoImportSelectionsForFacade(source SourceInfo) []ImportSelectionFor
// SourceInfoWithImportSelections copies converted root-owned records into
// 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 {
switch value {
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 {
if value == importSelectionNone {
return "none"
@@ -286,13 +257,6 @@ func importSelectionOutcomeString(value importSelectionOutcome) string {
return ""
}
func importSelectionOutcomeFromString(value string) importSelectionOutcome {
if value == "none" {
return importSelectionNone
}
return 0
}
// SourceInfoPlacementModeForFacade converts private placement evidence to its
// root-owned numeric representation.
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
@@ -304,13 +268,6 @@ func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool {
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
// version declaration observed during an enabled load.
type SourceVersionCheck struct {

View File

@@ -241,80 +241,15 @@ func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current refle
if name != "" {
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
}
inlineType := field.Type
seenPointers := make(map[reflect.Type]bool)
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"}
if issue := b.describeInlineField(descriptor, field, i, secret, path); issue != nil {
return issue
}
continue
}
fieldPath := path.Key(name)
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 {
if issue := b.describeOrdinaryField(descriptor, field, i, name, secret, path); issue != nil {
return issue
}
descriptor.members = append(descriptor.members, structMember{index: i, name: name, descriptor: fieldType})
}
return nil
}

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

View File

@@ -28,69 +28,50 @@ func validOverlayName(name string) bool {
return true
}
func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
allSources, diagnostics := loadSelectedOverlayInto(source, context, main, assembly, sources, name, rule, limits)
if len(diagnostics) != 0 {
return nil, allSources, diagnostics
}
return assembly.current, allSources, nil
type selectedOverlayRequest struct {
name string
filesystem resolvedFilesystemContext
protectionRule *VersionRule
sequencePolicy sequencePolicy
limits importLimits
versionState *sourceVersionState
}
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil)
func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext, main parsedSource, existing *resolvedFilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
if source.kind == sourceNone {
if context == nil {
value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")
return resolvedFilesystemContext{}, &value
}
}
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) {
var policy sequencePolicy
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)
func openSelectedOverlayAccess(resolved resolvedFilesystemContext) (*filesystemAccess, *Diagnostic) {
access, err := openResolvedFilesystemAccess(resolved)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
value.Primary = nil
return cloneSourceInfos(sources), []Diagnostic{value}
return nil, &value
}
if len(policies) == 0 {
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
}
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
return access, nil
}
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil)
}
func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState, policies ...sequencePolicy) ([]SourceInfo, []Diagnostic) {
var policy sequencePolicy
if len(policies) != 0 {
policy = policies[0]
}
func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo) ([]SourceInfo, []Diagnostic) {
policy := request.sequencePolicy
rule := request.protectionRule
limits := request.limits
versionState := request.versionState
closed := false
defer func() {
if !closed {
_ = access.close()
}
}()
overlayPath := access.join(access.rootPath, "overlays/"+name+".yml")
overlayPath := access.join(access.rootPath, "overlays/"+request.name+".yml")
canonical, err := access.canonical(overlayPath)
if err != nil {
code := CodeOverlayInvalid
@@ -123,7 +104,7 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
sourceID := "source-" + strconv.Itoa(len(sources)+1)
overlayLocation := &Location{SourceID: sourceID}
kind := SourceKindFile
label := "overlays/" + name + ".yml"
label := "overlays/" + request.name + ".yml"
if access.native != nil {
kind = SourceKindFilesystem
label = access.label + "/" + canonical
@@ -145,66 +126,44 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
if base.current.inputNodes+nodeCount > limits.nodes {
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
}
var processed sourceVersionProcessingResult
var failures []Diagnostic
if len(policies) != 0 {
if versionState != nil {
processed, failures = versionState.processWithDirectiveContext(
overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping, rootDirectiveContextOverlay,
)
} else {
processed = processSourceVersionWithDirectiveContext(
overlayRoot, rule, sourceVersionContribution, Path{}, sourceVersionModeDefault, rootDirectiveContextOverlay,
)
failures = processed.diagnostics
}
if versionState != nil {
processed, failures = versionState.processWithDirectiveContext(
overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping, rootDirectiveContextOverlay,
)
} else {
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processed, failures = versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
} else {
processed = sourceVersionProcessingResult{payload: overlayRoot}
}
processed = processSourceVersionWithDirectiveContext(
overlayRoot, rule, sourceVersionContribution, Path{}, sourceVersionModeDefault, rootDirectiveContextOverlay,
)
failures = processed.diagnostics
}
if len(failures) != 0 {
return sources, failures
}
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()
closed = true
if closeErr != nil {
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
}
var overlayDiagnostics []Diagnostic
if len(policies) != 0 {
overlayDiagnostics = base.applyOverlayWithCollectionPolicy(overlayPayload, nil, collectionPolicyScope{}, nil, LayerOverlay, policy, processed.sequences)
} else {
overlayDiagnostics = base.applyOverlay(overlayPayload, nil, nil, LayerOverlay)
}
overlayDiagnostics := base.applyOverlayWithCollectionPolicy(overlayPayload, nil, collectionPolicyScope{}, nil, LayerOverlay, policy, processed.sequences)
if len(overlayDiagnostics) != 0 {
return sources, overlayDiagnostics
}
if len(policies) != 0 {
// The selected overlay retains one application with the actual item
// assignments. Raw list indexes cannot identify an upsert's destination.
application := base.current.applications[len(base.current.applications)-1]
if node := versionClaimFromApplication(application, rule); node != nil {
location := publicLocation(node.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,
}}
}
// The selected overlay retains one application with the actual item
// assignments. Raw list indexes cannot identify an upsert's destination.
application := base.current.applications[len(base.current.applications)-1]
if node := versionClaimFromApplication(application, rule); node != nil {
location := publicLocation(node.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,
}}
}
base.current.inputBytes += int64(len(data))
base.current.inputNodes += nodeCount

View File

@@ -2,11 +2,14 @@ package engine
import (
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"reflect"
"runtime"
"testing"
"testing/fstest"
)
type overlayTestConfig struct {
@@ -21,6 +24,81 @@ type overlayTestConfig struct {
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) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
@@ -507,7 +585,21 @@ func TestLoadOverlayCountsAgainstAggregateBudgets(t *testing.T) {
}
base.inputBytes = main.bytes
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 {
t.Fatalf("diagnostics = %#v", diagnostics)
}

View File

@@ -21,17 +21,21 @@ const (
// Source is an opaque configuration input. Construct sources with File,
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
type Source struct {
kind sourceKind
path string
label string
namespace *nativeNamespace
data []byte
reader *readerSource
mapping *document.Node
mapIssue *valueIssue
mapBytes int64
mapNodes int
createErr sourceCreationError
kind sourceKind
path string
label string
namespace *nativeNamespace
data []byte
reader *readerSource
mapping *document.Node
mapIssue *valueIssue
mapBytes int64
mapNodes int
createErr sourceCreationError
search *Search
searchApp string
searchRequired bool
searchIssues []string
}
type sourceKind uint8
@@ -44,6 +48,7 @@ const (
sourceReader
sourceMapping
sourceNone
sourceSearch
)
type sourceCreationError uint8
@@ -174,7 +179,9 @@ func Mapping(label string, values map[string]any) 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 {
return Source{kind: sourceNone}
}

View File

@@ -7,58 +7,18 @@ import (
"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 {
return nil, nil
}
var rootPath, basePath string
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 {
if resolved == 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
if filesystem != nil {
rooted = rootedNativeFS{filesystem: filesystem, root: rootPath}
@@ -77,7 +37,7 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
}
var relative string
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)
return nil, &value
}

View File

@@ -47,7 +47,6 @@ type sourceVersionState struct {
localRule *VersionRule
effectiveRule *VersionRule
mode sourceVersionMode
importDefaults importDefaults
mainProcessing *sourceVersionProcessingResult
evidence []*sourceVersionEvidence
diagnostics []Diagnostic

View File

@@ -539,6 +539,41 @@ func TestSourceVersionNoSourceMainRetainsAbsentCallback(t *testing.T) {
if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
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)
}
assembly := newAssemblyAccumulator(base)
_, diagnostics = loadSelectedOverlayIntoWithVersion(
File(mainPath), nil, main, assembly, sourceInfos(main.source), "testing", effectiveRule, defaultImportLimits(), state,
)
resolved, diagnostic := resolveFilesystemContext(File(mainPath), nil, main, nil)
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 {
t.Fatalf("load selected overlay: %#v", diagnostics)
}
@@ -1488,9 +1536,11 @@ func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T)
limits.nodes = wantNodes
accounting := &importAccounting{}
composed, sources, foundDiagnostics := loadImportedDocumentsTrackedWithVersion(
File(mainPath), nil, main, Path{}, rule, limits, accounting, nil, nil, state,
)
request := testImportRequest(File(mainPath), nil, main, Path{}, rule, nil)
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 {
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.nodes = wantNodes - 1
accounting = &importAccounting{}
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
)
request = testImportRequest(File(mainPath), nil, main, Path{}, rule, nil)
policy.importLimits = below
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 {
t.Fatalf("raw header nodes were not charged: %#v, %#v", composed, foundDiagnostics)
}
@@ -1515,10 +1566,11 @@ func TestSourceVersionImportAccountingIncludesConsumedHeaderNodes(t *testing.T)
below = defaultImportLimits()
below.bytes = wantBytes - 1
accounting = &importAccounting{}
composed, _, foundDiagnostics = loadImportedDocumentsTrackedWithVersion(
File(mainPath), nil, main, Path{}, rule, below, accounting, nil, nil,
&sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired},
)
request = testImportRequest(File(mainPath), nil, main, Path{}, rule, nil)
policy.importLimits = below
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 {
t.Fatalf("raw header bytes were not charged: %#v, %#v", composed, foundDiagnostics)
}

View File

@@ -88,13 +88,6 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
}
}
}
if options.Overrides != nil {
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,
}
}
}
result.Overrides = toEngineOverrides(options.Overrides)
return result
}

View File

@@ -83,8 +83,10 @@ type ImportDefaults struct {
}
// Options contains the application definition and per-load input selections.
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
// Overrides contains only values explicitly supplied by an application CLI.
// Imports enables traversal of declarations in a selected main document; an
// 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
// document's local coordinates. Placement projects checks and main protection
// 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,
// BaseDir and Root use absolute host paths. With FS set, they 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.
// BaseDir and Root use absolute host paths. For NoSource, Root is required and
// BaseDir may be omitted to use Root as the import base. With FS set, BaseDir
// 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 {
BaseDir string
Root string
@@ -319,14 +323,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
if options.Filesystem != nil {
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
}
if options.Overrides != nil {
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,
}
}
}
result.Overrides = toEngineOverrides(options.Overrides)
return result
}

View File

@@ -1,6 +1,8 @@
package configkit
import (
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
@@ -27,16 +29,11 @@ func TestOverlayPublicValues(t *testing.T) {
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
destination := Keys("collections", "a/b", "0")
public := SourceInfo{
ID: "source-1", Destination: destination,
PlacementMode: PlacementModeSequenceItem, DestinationUnresolved: true,
}
internal := toEngineSourceInfo(public)
if internal.Destination.String() != destination.String() || engine.SourceInfoPlacementModeForFacade(internal) != uint8(PlacementModeSequenceItem) || !engine.SourceInfoDestinationUnresolvedForFacade(internal) {
t.Fatalf("engine source evidence = %#v", internal)
}
path := writeRecordConfig(t, "value: main\n")
options := engine.OptionsWithPlacement(engine.Options[map[string]any]{}, engine.Keys("collections", "a/b", "0"), uint8(PlacementModeSequenceItem))
internal := engineSourceInfoFromLoad(t, path, "source-1", options)
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)
}
copy := cloneSourceInfo(converted)
@@ -47,58 +44,116 @@ func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
}
func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
path := Keys("records").Index(2).Key("version")
source := SourceInfo{
ID: "source-1",
VersionCheck: &SourceVersionCheck{
Path: path, Version: Version{Present: true, Text: "123"},
Location: &Location{SourceID: "source-1", Line: 4, Column: 8},
path := Keys("version")
file := writeRecordConfig(t, "version: '123'\nvalue: main\n")
options := engine.Options[recordConfig]{
Definition: engine.Definition[recordConfig]{
Version: &engine.VersionRule{
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)
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)
}
internal := engineSourceInfoFromLoad(t, file, "source-1", options)
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.VersionCheck.Path = Keys("changed")
copy.VersionCheck.Version.Text = "changed"
copy.VersionCheck.Location.Line = 99
copy.VersionCheck.Redacted = true
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)
}
}
func TestImportSelectionConversionsAndCopies(t *testing.T) {
declaration, err := ParsePath("/configkit/imports/2")
if err != nil {
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")
file := writeRecordConfig(t, "configkit:\n imports:\n - glob: absent/*.yml\nvalue: main\n")
internal := engineSourceInfoFromLoad(t, file, "source-1", engine.Options[recordConfig]{Imports: true})
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)
}
copy := cloneSourceInfo(converted)
copy.ImportSelections[0].Path = Keys("copy")
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)
}
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)})
if empty.ImportSelections == nil {
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{}
}

View File

@@ -8,7 +8,8 @@ import (
)
// 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 }
// 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)}
}
// 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 (s Source) toEngine() engine.Source { return s.value }