Compare commits
30 Commits
d0a605afbb
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5959537d1a | |||
| 0369111d30 | |||
| 2fedb0307f | |||
| 087050fa25 | |||
| 1ac1f8bc9e | |||
| 4dc3e70877 | |||
| f64497cb13 | |||
| 68cf5c489b | |||
| e50fee2ca5 | |||
| eec214944c | |||
| 4f2c113237 | |||
| 2d93a5c703 | |||
| 2f6c1eca00 | |||
| 8642337966 | |||
| 53ce6c63d8 | |||
| 2f0900535d | |||
| 4748313105 | |||
| e2634c8815 | |||
| 424c89780e | |||
| 92309ddcc3 | |||
| 7ff204bf3d | |||
| b3ec860fac | |||
| 435dea102f | |||
| ea46001f78 | |||
| a0e5693d07 | |||
| 5c5f2af6cc | |||
| 8253993005 | |||
| d0848290da | |||
| e94a0642ff | |||
| 571d063675 |
@@ -45,6 +45,11 @@ ConfigKit does not search conventional application paths implicitly. See the
|
||||
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||
[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"`
|
||||
|
||||
90
discovery.go
Normal file
90
discovery.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
|
||||
// Search is an immutable ordered description of possible host-file inputs.
|
||||
// It is consumed only when passed to SearchConfig and used by a load.
|
||||
type Search struct{ value engine.Search }
|
||||
|
||||
// FilenameGroup selects filenames in order for a directory search.
|
||||
// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames.
|
||||
type FilenameGroup struct{ value engine.FilenameGroup }
|
||||
|
||||
// SearchOptions controls whether a search must select a file.
|
||||
type SearchOptions struct {
|
||||
// Required reports a missing-source error if the complete search is exhausted.
|
||||
Required bool
|
||||
}
|
||||
|
||||
// DirectorySearchOptions selects the filename groups used by SearchDirectory.
|
||||
type DirectorySearchOptions struct {
|
||||
// Filenames concatenates groups in order. A nil slice uses Named then Generic;
|
||||
// a non-nil empty slice selects no filenames.
|
||||
Filenames []FilenameGroup
|
||||
}
|
||||
|
||||
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||
func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} }
|
||||
|
||||
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||
func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} }
|
||||
|
||||
// CustomFilenames selects literal filename components in the supplied order.
|
||||
// Names cannot be empty or contain a directory component.
|
||||
func CustomFilenames(names ...string) FilenameGroup {
|
||||
return FilenameGroup{value: engine.CustomFilenames(names...)}
|
||||
}
|
||||
|
||||
// SearchDirectory describes a search within directory. Omitted options or a nil
|
||||
// Filenames slice uses Named then Generic groups. Explicitly empty Filenames
|
||||
// selects no names and does not resolve the directory base.
|
||||
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||
options := make([]engine.DirectorySearchOptions, len(supplied))
|
||||
for index, option := range supplied {
|
||||
if option.Filenames == nil {
|
||||
continue
|
||||
}
|
||||
options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames))
|
||||
for groupIndex, group := range option.Filenames {
|
||||
options[index].Filenames[groupIndex] = group.value
|
||||
}
|
||||
}
|
||||
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||
}
|
||||
|
||||
// Paths describes literal host-file candidates in the supplied order.
|
||||
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||
|
||||
// Searches concatenates descriptions in order and snapshots their entries.
|
||||
func Searches(searches ...Search) Search {
|
||||
values := make([]engine.Search, len(searches))
|
||||
for index, search := range searches {
|
||||
values[index] = search.value
|
||||
}
|
||||
return Search{value: engine.Searches(values...)}
|
||||
}
|
||||
|
||||
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||
// Loading on an unsupported platform fails if this preset is reached.
|
||||
func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} }
|
||||
|
||||
// UserConfigDirectory describes searches rooted at the Go user-config directory.
|
||||
func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} }
|
||||
|
||||
// HomeDirectory describes searches rooted at the Go home directory.
|
||||
func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} }
|
||||
|
||||
// WorkingDirectory describes application-named files in the current directory.
|
||||
func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} }
|
||||
|
||||
// SearchConfig creates a source that selects the first matching host file when
|
||||
// consumed by Load or LoadLayered. Without Required, exhausting the search uses
|
||||
// the absent-input lifecycle. Description and option errors are reported while
|
||||
// loading.
|
||||
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||
options := make([]engine.SearchOptions, len(supplied))
|
||||
for index, option := range supplied {
|
||||
options[index] = engine.SearchOptions{Required: option.Required}
|
||||
}
|
||||
return Source{value: engine.SearchConfig(application, search.value, options...)}
|
||||
}
|
||||
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
Applications often need a small ordered convention for choosing a main
|
||||
configuration file. Keeping that choice entirely in each application repeats
|
||||
path selection and file-loading plumbing. Moving the convention into ConfigKit
|
||||
without an explicit declaration would make ordinary loads search paths that a
|
||||
consumer did not choose, obscuring both precedence and filesystem access.
|
||||
|
||||
ConfigKit already owns synchronous source acquisition, parsing, composition,
|
||||
decoding, validation, and inspection. A selected host file can therefore enter
|
||||
that lifecycle as the existing file source, preserving its source identity,
|
||||
resource context, and diagnostics. Optional exhaustion can use the same absent
|
||||
main lifecycle as an explicitly absent source.
|
||||
|
||||
## Decision
|
||||
|
||||
Consumers choose and order the main-file conventions they want to use. They may
|
||||
describe literal paths, directory filename groups, and supported directory
|
||||
presets, and may mark the complete search as required. Search descriptions are
|
||||
explicit inputs to loading; no preset or convention is activated implicitly.
|
||||
|
||||
During synchronous loading, ConfigKit resolves the description to one real host
|
||||
file or to absence before parsing. The selected file follows the existing source
|
||||
acquisition and processing rules. A selected-file access, parse, version,
|
||||
composition, decode, or validation failure is terminal and does not resume the
|
||||
search. Optional exhaustion shares the absent-main lifecycle; required
|
||||
exhaustion reports a missing-source diagnostic. No candidate-attempt history is
|
||||
added to public metadata.
|
||||
|
||||
This keeps convention choice with applications while moving repeated selection
|
||||
and loading plumbing into the library's established lifecycle. It preserves
|
||||
explicit control, ordinary source behavior, and complete-on-return ownership.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep all search and fallback loading in each application.** This preserves
|
||||
consumer ownership but repeats the file-selection and load coordination that
|
||||
ConfigKit already owns, and risks divergent handling of selected-file errors.
|
||||
- **Search conventional locations implicitly during every load.** This reduces
|
||||
setup, but activates filesystem access and precedence the consumer did not
|
||||
select and makes ordinary source behavior less predictable.
|
||||
- **Return candidate paths for the application to load one by one.** This keeps
|
||||
selection outside the load lifecycle and encourages repeated complete loads,
|
||||
potentially rerunning defaults, callbacks, and validation during fallback.
|
||||
|
||||
## Consequences
|
||||
|
||||
The public search description must be immutable and root-owned, with private
|
||||
engine conversion and selection. The contract documents candidate ordering,
|
||||
selection, exhaustion, context, and provenance; the API declaration documents
|
||||
the public symbols. Dynamic bases and file availability are observed when each
|
||||
load consumes the description. Applications remain responsible for selecting
|
||||
conventions and for their schema and domain requirements.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented behavior, and the
|
||||
[component overview](../internal/overview.md) describes implementation ownership.
|
||||
The completed implementation plan and remediation record are preserved in Git
|
||||
history at `0369111` under `docs/roadmap/`.
|
||||
@@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors:
|
||||
| Constructor | Current contract |
|
||||
| --- | --- |
|
||||
| `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
|
||||
|
||||
@@ -55,6 +55,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
|
||||
|
||||
@@ -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
|
||||
@@ -167,6 +168,27 @@ The subsequent import-integration audit and completed remediation record are
|
||||
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||
|
||||
The completed sequence-identity/collection-policy roadmap and implementation
|
||||
plan are preserved in Git history at `d0a605a`:
|
||||
|
||||
```sh
|
||||
git show d0a605a:docs/roadmap/sequences.md
|
||||
git show d0a605a:docs/roadmap/implementation.md
|
||||
```
|
||||
|
||||
Current behavior belongs to the configuration contract's
|
||||
[strict composition](config.md#strict-document-composition),
|
||||
[sibling inheritance](config.md#sibling-inheritance), and
|
||||
[CLI overrides](config.md#explicit-cli-overrides) sections.
|
||||
The completed remediation record, including the algorithm-audit findings, is
|
||||
preserved at `ea46001:docs/roadmap/remediation.md`.
|
||||
|
||||
The completed configuration-discovery roadmap, implementation plan, and
|
||||
remediation record are preserved in Git history at `0369111`, for example
|
||||
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
|
||||
[configuration discovery](config.md#configuration-discovery); the public
|
||||
[discovery declarations](../discovery.go) own the Go API.
|
||||
|
||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
@@ -216,6 +238,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| --- | --- | --- |
|
||||
| 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'` |
|
||||
| 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'` |
|
||||
|
||||
@@ -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,7 @@ 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.
|
||||
|
||||
@@ -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
77
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# ConfigKit v0.10.0
|
||||
|
||||
This minor release adds explicit, composable discovery of a configuration file.
|
||||
Applications choose the search conventions and their order; ConfigKit selects one
|
||||
file during loading and returns the final configuration with its normal metadata.
|
||||
Optional searches that find no file share the expanded `NoSource()` lifecycle.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add `SearchConfig`, immutable `Search` and `FilenameGroup` descriptions, and
|
||||
search-wide requiredness through `SearchOptions`. Compose explicit `Paths`,
|
||||
custom `SearchDirectory` descriptions, and built-in Unix system, user-config,
|
||||
home, or working-directory presets with `Searches`. See the
|
||||
[public declarations](../../discovery.go) and
|
||||
[discovery contract](../config.md#configuration-discovery).
|
||||
- Expand searches in supplied order, trying a directory's filename groups before
|
||||
moving to the next directory. Named and generic groups provide conventional
|
||||
YAML filenames; custom groups provide literal names. Directory searches
|
||||
distinguish omitted filename selection from an explicitly empty selection.
|
||||
Constructors perform no filesystem access, and each load observes current
|
||||
directory bases and file availability.
|
||||
- Select the first existing candidate. Access errors and failures while loading
|
||||
the selected file terminate the load rather than trying another configuration.
|
||||
Selected files retain physical source identity, placement, provenance, imports,
|
||||
and relative-resource context. Discovery works with ordinary loading and
|
||||
layered inputs, including underlays, without adding a precedence tier.
|
||||
- Permit enabled imports with an absent main input: there are no main import
|
||||
declarations to traverse, while actual underlay sources retain normal imports.
|
||||
Support ordinary selected overlays with an absent main and explicit filesystem
|
||||
context, including a root-only host context. Layered overlays retain their
|
||||
independent `OverlayFilesystem` requirement. See
|
||||
[single-source loading](../config.md#single-source-loading) and
|
||||
[layered loading](../config.md#layered-loading).
|
||||
- Preserve ordinary file-context behavior after discovery and include the
|
||||
failing candidate's file identity in access diagnostics. Use Go's
|
||||
platform-aware Windows component rules and native drive-root-relative path
|
||||
resolution. Isolate user-config tests according to the host Go conventions.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
Existing explicit source constructors and loading APIs remain available.
|
||||
`File(path)` remains an exclusive selection that fails for a missing file;
|
||||
discovery is activated only by explicitly supplying `SearchConfig`.
|
||||
|
||||
Searches are optional by default. Set `SearchOptions{Required: true}` when the
|
||||
application must find a file. An exhausted optional search contributes no main
|
||||
document or source record; application version checks, defaults, resolution,
|
||||
and validation still run under their existing rules and may reject incomplete
|
||||
configuration. A selected overlay is still required to exist.
|
||||
|
||||
The former `NoSource()` rejections of enabled imports and explicit filesystem
|
||||
context are relaxed. An active ordinary overlay with no main file now loads
|
||||
when explicit context is supplied. Applications that relied on those combinations
|
||||
being rejected should review their option handling. Selected host files derive
|
||||
their own base directory; do not supply an explicit `BaseDir` or native filesystem
|
||||
context for a search that selects a host file.
|
||||
|
||||
Applications may replace their own fallback-loading loops with the new search
|
||||
descriptions. The maintained [consumer examples](../consumers/README.md#explicit-main-file-discovery)
|
||||
show simple preset selection and custom-first discovery alongside existing
|
||||
configuration tiers. Composition, collection operations, inheritance, CLI
|
||||
precedence, and the complete-on-return ownership model retain their existing
|
||||
semantics.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. Release validation uses Go 1.26.10.
|
||||
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer
|
||||
tool remains ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated
|
||||
targets; macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/docs/oss/attribution.md)
|
||||
111
docs/releases/v0.9.0.md
Normal file
111
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# ConfigKit v0.9.0
|
||||
|
||||
This minor release expands import selection, adds author-declared collection
|
||||
identity and operations, and supports inheritance between identified records.
|
||||
It also moves CLI assignments after inheritance and typed decoding. These changes
|
||||
include breaking API and configuration behavior changes from v0.8.0; review the
|
||||
migration guidance below before upgrading.
|
||||
|
||||
## Changes
|
||||
|
||||
- Extend imports with named-file and directory lists, directory filename patterns,
|
||||
exclusions, recursion and depth controls, and explicit hidden-file and symlink
|
||||
discovery controls. Add `ImportDefaults` to ordinary and layered options, with
|
||||
authored declarations taking precedence over application defaults. Imports are
|
||||
optional by default. `SourceInfo.ImportSelections` exposes completed optional
|
||||
declarations that selected no sources. See the
|
||||
[import contract](../config.md#explicit-imports) and
|
||||
[consumer guidance](../consumers/README.md#import-defaults-and-selection-inspection).
|
||||
- Use shared dependency-ready ordering for import composition and sibling
|
||||
inheritance. Dependencies complete before dependents, with deterministic
|
||||
selection among currently eligible contributions. Source acquisition and
|
||||
callback order remain separate from assembly order. See the
|
||||
[ordering decision](../adr/0015-shared-dependency-ready-ordering.md).
|
||||
- Add root `configkit.sequences` declarations for collection identity and
|
||||
`replace`, `append`, or `upsert` operations, including in selected overlays.
|
||||
Strict peers combine distinct identities and reject duplicates; precedence
|
||||
and inheritance apply the destination's collection operation. Upsert replaces
|
||||
a whole matching record at its retained position. Identity supports compound
|
||||
field sets and scalar strings, numbers, booleans, and null. See the
|
||||
[collection contract](../config.md#strict-document-composition).
|
||||
- Add `Definition.SequenceIdentities`, exact `SequenceIdentityAt` registrations,
|
||||
and dynamic `SequenceIdentityMatching` selectors. Application registrations
|
||||
govern structural collection operations and check existing matching final
|
||||
typed sequences, including values from defaults, CLI assignments, and
|
||||
application resolution. YAML-only declarations govern structural operations
|
||||
without adding that final-result check.
|
||||
- Support record inheritance within an identified sequence using an `extends`
|
||||
mapping containing the parent's complete identity. The child retains its own
|
||||
identity, relationships use surviving assembled records, and output sequence
|
||||
order is preserved. See [sibling inheritance](../config.md#sibling-inheritance)
|
||||
and the maintained [sequence catalog](../consumers/README.md#identified-prompt-sequences).
|
||||
- Apply explicit CLI overrides to the decoded configuration before application
|
||||
resolution. Mappings retain omitted fields and their presence metadata;
|
||||
sequences replace whole lists independently of collection operations. See the
|
||||
[CLI contract](../config.md#explicit-cli-overrides).
|
||||
- Correct native import alias and contained-symlink handling, source-version
|
||||
path projection, and provenance/history through collection movement,
|
||||
replacement, inheritance, and CLI assignment. Reduce repeated scans,
|
||||
accumulated policy copying, and sparse-projection storage during construction.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
This pre-v1 minor release intentionally changes the v0.8.0 contracts:
|
||||
|
||||
1. **Migrate import requiredness.** The YAML `optional` field is removed and is
|
||||
rejected. Replace `optional: true` with `required: false`; omission is equivalent
|
||||
when the application does not default imports to required. Replace
|
||||
`optional: false` with `required: true`. Imports that previously relied on
|
||||
implicit requiredness now need `required: true`, or an application
|
||||
`ImportDefaults.Required` default set to true. Review discovery filters:
|
||||
`follow` and `hidden` default to true, and directory searches select all
|
||||
filenames unless a pattern is supplied.
|
||||
2. **Replace sequence registrations.** `SequenceComposition` and
|
||||
`Definition.SequenceCompositions` are removed without compatibility aliases.
|
||||
For a previous registration at `prompts` using `id`, use:
|
||||
|
||||
```go
|
||||
rules.SequenceIdentities = []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
}
|
||||
```
|
||||
|
||||
These registrations now also enforce identity in final typed sequences. Fix
|
||||
missing or duplicate identities introduced by defaults, CLI assignments, or
|
||||
application resolution. Registration does not require a collection to exist
|
||||
or determine its allowed shape; schema and application validation retain
|
||||
those responsibilities.
|
||||
3. **Review collection precedence and ordering.** An identified collection now
|
||||
defaults to upsert across layers and inheritance when no explicit mode
|
||||
applies, rather than unconditional whole-sequence replacement. Declare
|
||||
`mode: replace` at the relevant destination where replacement is intended.
|
||||
With append or upsert, `[]` contributes no records and does not clear the
|
||||
collection; null is invalid. Use `mode: replace` with `[]` to clear it.
|
||||
An explicit mode persists at its destination until changed; a parent's mode
|
||||
does not transfer to a different inheriting destination. Unidentified
|
||||
sequences retain strict peer conflicts and whole-value precedence
|
||||
replacement. Dependency-ready assembly can change sequence positions and
|
||||
diagnostic or history order compared with the former traversal order.
|
||||
4. **Review CLI recovery and inheritance assumptions.** CLI overrides can no
|
||||
longer repair values that fail earlier decoding, bypass source or inheritance
|
||||
errors, or add or cancel inheritance relationships. Supply structural
|
||||
directives in source configuration. Application resolution still receives
|
||||
explicit CLI values and may record subsequent normalization or derivation.
|
||||
5. **Update affected Go literals and diagnostic handling.** Use keyed literals
|
||||
for expanded option and source-information structs. Collection validation adds
|
||||
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||
(`sequence.conflict`); update diagnostic handling where applicable.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.9.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
|
||||
macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/docs/oss/attribution.md)
|
||||
@@ -161,9 +161,10 @@ syntax, dependencies, or complexity-based gates.
|
||||
|
||||
## Relationship To Remediation And Other Work
|
||||
|
||||
[Remediation](remediation.md) owns the identified correctness and algorithmic
|
||||
efficiency findings. Several overlap these files, particularly item-scope
|
||||
application matching and inheritance policy localization.
|
||||
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
|
||||
@@ -172,9 +173,9 @@ 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.
|
||||
[Configuration discovery](../config.md#configuration-discovery) is implemented.
|
||||
Import cleanup should preserve its source-acquisition boundary and behavior as
|
||||
part of this refactor.
|
||||
|
||||
## Work Needed And Validation
|
||||
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,905 +0,0 @@
|
||||
# Sequence Implementation Review Remediation
|
||||
|
||||
Status: findings 1–17 are complete. The algorithm audit below identifies six
|
||||
additional open findings, numbered 18–23. This record introduces no new release
|
||||
gate.
|
||||
|
||||
Reviewed commit: `0be7cdb00eff576fda6b30c0600b2134d4e2e086`.
|
||||
Implementation range: `bc6b977..0be7cdb`.
|
||||
Authority: the [sequence roadmap](sequences.md),
|
||||
[implementation plan](implementation.md),
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md), and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||
|
||||
The public facade, shared identity/composition machinery, scoped policies,
|
||||
identified-record inheritance, post-decode CLI phase, and final registered
|
||||
identity validation are present. The findings below concern remaining
|
||||
correctness, contract-alignment, documentation, and scaling gaps.
|
||||
|
||||
Validation on the reviewed commit:
|
||||
|
||||
- `./scripts/check.sh` passed with Go 1.26.10 on Linux.
|
||||
- `go test -race ./...` passed.
|
||||
- Public-API probes outside the repository reproduced findings 1–9. The probes
|
||||
used labeled byte sources, defaults, and an in-memory filesystem; they did not
|
||||
change implementation files or use external services.
|
||||
- Finding 10 follows directly from the maintained text; finding 11 follows from
|
||||
the prefix-scan loop, not a timing comparison or performance threshold.
|
||||
|
||||
Validation after the initial remediation: `./scripts/check.sh` and
|
||||
`go test -race ./...` passed with Go 1.26.10 on Linux. The original public-API
|
||||
probes for findings 1–9 now demonstrate the required corrected behavior.
|
||||
|
||||
Validation after integration remediation: the everyday check, full race suite,
|
||||
and local documentation/public-boundary checks passed with the same toolchain.
|
||||
The original and follow-up public-API probes demonstrate corrected behavior for
|
||||
findings 1–9 and 12–15, including unrelated underlay payload at an old version
|
||||
index. The accumulated-copy correction is verified by code inspection and
|
||||
ownership regressions; the contract corrections are verified against the
|
||||
implemented operations and replay. Each additional finding has its own completion
|
||||
record and commit.
|
||||
|
||||
P1 denotes the highest-priority correctness issue here; P2 denotes normal-priority
|
||||
work needed to complete the selected behavior. Preserve the agreed scope while
|
||||
fixing these issues. In particular, do not resolve failures by restricting valid
|
||||
identity fields, requiring repeated declarations, changing merge semantics, or
|
||||
adding limits.
|
||||
|
||||
## 1. Preserve Retained Explicit Values In Nested CLI Mapping Assignments — P1
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: override metadata now reads the actual target subtree through one
|
||||
shared lookup. Ordinary/layered regressions preserve retained values, including
|
||||
zero and false, their explicit origins, and absence-based resolver behavior.
|
||||
Verified `go test ./internal/engine -run 'Override|CLI' -count=1` and
|
||||
`./scripts/check.sh`.
|
||||
|
||||
Owner: [`applyDecodedOverrides`](../../internal/engine/override_decode.go),
|
||||
especially lines 59–60 and the presence/origin update helpers.
|
||||
|
||||
The helpers receive the complete configuration projection as `existing`, but
|
||||
interpret it as the subtree at the override path. A partial nested mapping
|
||||
therefore treats retained input children as absent and drops their presence and
|
||||
origins. `fillDefaultOrigins` subsequently labels those retained values as defaults.
|
||||
|
||||
Reproduction: load the following into ordinary nested Go structs:
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
nested:
|
||||
a: 1
|
||||
b: 2
|
||||
```
|
||||
|
||||
Apply an override at `[settings]` with value
|
||||
`map[string]any{"nested": map[string]any{"a": 3}}`. The typed value of `b`
|
||||
initially remains 2, but `Metadata.Lookup([settings,nested,b])` reports
|
||||
`Explicit: false` and a default origin. Calling `Default` for that field in
|
||||
application resolution then changes the explicitly supplied 2 to the fallback
|
||||
value. This violates presence-based defaulting, not merely presentation.
|
||||
|
||||
Required remediation: use the actual existing target subtree when updating
|
||||
presence/origins, preserving retained descendants and removing genuinely replaced
|
||||
ones. Keep override batches detached and preserve the selected post-decode order.
|
||||
Add regressions for nested partial mappings, retained zero/false values, metadata,
|
||||
and an absence-based resolver default that must not overwrite retained input.
|
||||
|
||||
## 2. Read Upsert Origins At Input Paths And Write Them At Output Paths — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: sequence origin copying now separates incoming and effective paths
|
||||
for precedence and inheritance. A regression covers reordered matches, appended
|
||||
records, composite origins, and actual resource reads from distinct declaring
|
||||
directories. Verified focused origin/inheritance/overlay/provenance tests,
|
||||
`go test -race ./internal/engine`, and `./scripts/check.sh`.
|
||||
|
||||
Owners: [`applyOverlaySequenceEvidence`](../../internal/engine/overlay.go),
|
||||
particularly lines 369 and 381, and the analogous sequence-origin copying in
|
||||
[`inheritance_collection.go`](../../internal/engine/inheritance_collection.go).
|
||||
|
||||
The overlay helper supplies an output-index path to an origin lookup whose keys
|
||||
are still incoming-index paths. When those positions differ and another incoming
|
||||
record occupies the output index, metadata takes that other record's source
|
||||
location. The same defect was reproduced through mapping-sibling inheritance;
|
||||
its helper uses the same single-path copying pattern.
|
||||
|
||||
Reproduction: an underlay supplies IDs `[a,b]`; the main source declares `key: id`
|
||||
and supplies `[b*,a*]`. The result is correctly `[a*,b*]`, but the value origin
|
||||
for final `a*` is the source line of `b*`, and vice versa. In the probe the two
|
||||
value lines were 7 and 9; final index 0 incorrectly reported line 7 instead of 9.
|
||||
With separately authored records this can select a different source/context.
|
||||
|
||||
Required remediation: use the operation's input-to-output projection for origin
|
||||
subtree reads as well as writes. Preserve composite contributing origins, not
|
||||
just node-location fallbacks. Test reordered upserts and appended records with
|
||||
distinct source files/locations, including nested fields, inheritance operations,
|
||||
and declaring resource contexts.
|
||||
|
||||
## 3. Apply Tier Sequence Operations To Historical Contributions — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: per-source history now composes strict item projections with the
|
||||
tier operation, including nested application prefixes and local sequence replay.
|
||||
Regressions cover all three operations, multiple peers, retained records, removed
|
||||
fields, source attribution, and a subsequent overlay without a false decoding
|
||||
event. Verified focused history tests, `go test -race ./internal/engine`, and
|
||||
`./scripts/check.sh`.
|
||||
|
||||
Owner: [`applyOverlayWithCollectionPolicy`](../../internal/engine/overlay.go),
|
||||
lines 76–84, and existing sequence-history projection/replay.
|
||||
|
||||
For composed tiers, `applications` is nonempty. This branch appends the tier's
|
||||
original strict-composition applications without incorporating the newly computed
|
||||
precedence sequence descriptors. Replay consequently performs a whole-list
|
||||
replacement or strict append rather than the actual cross-tier upsert/append.
|
||||
The later decoding reconciliation conceals the mismatch by attributing a
|
||||
structural combination to typed defaults/decoding.
|
||||
|
||||
Reproduction: lower `[a,b]` and higher `[b*,a*]` correctly yield `[a*,b*]`.
|
||||
`Explain([items])` instead shows the main contribution producing `[b*,a*]`, then
|
||||
a `decoding` event reordering it to `[a*,b*]` with the reason "applied retained
|
||||
defaults and canonical typed decoding". No decoder performed that reordering.
|
||||
|
||||
Required remediation: project each retained per-source application through the
|
||||
tier's selected operation while keeping peer composition distinct from tier
|
||||
precedence. Do not collapse authored sources or fake decoding events. Cover
|
||||
upsert, append, unmatched retained records, reordered matches, removed fields,
|
||||
multiple higher-tier peers, and a subsequent selected overlay. Replay should
|
||||
agree with structural assembly before real decoding reconciliation.
|
||||
|
||||
## 4. Reproject Nested Source Destinations After Cross-Tier Item Movement — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: layered assembly projects incoming source destinations and version
|
||||
classification evidence with the same item assignments used for values/history.
|
||||
Earlier contributions retain their historical coordinates, physical identities
|
||||
and contexts. Main/underlay regressions cover reordered upserts, append, replace,
|
||||
nested helpers, and source-local version evidence. Verified focused destination,
|
||||
origin and source-version tests, focused race coverage, and `./scripts/check.sh`.
|
||||
|
||||
Owners: [`projectAcquiredDestinations`](../../internal/engine/imports.go) and the
|
||||
sequence-operation handoff in [`assembleLayeredInput`](../../internal/engine/load_request.go).
|
||||
|
||||
Source destinations are projected while composing their own tier, but are not
|
||||
reprojected when that tier's records move to accumulated-list positions. Nested
|
||||
helper source records can therefore point at a different final record.
|
||||
|
||||
Reproduction: lower `items` contains `[a,b]`. Main imports a single `b` record
|
||||
with `placement: sequence-item` into `[items]`; that record imports a helper
|
||||
mapping supplying its value. Upsert retains `a` at index 0 and places the higher
|
||||
`b` at index 1. `Sources()` nevertheless reports the surviving helper's destination
|
||||
as `/items/0`, its tier-local position, rather than `/items/1`.
|
||||
|
||||
Required remediation: carry the sequence operation's projection through nested
|
||||
source-destination/evidence updates at precedence boundaries. Keep physical
|
||||
source identity and passive resource contexts unchanged, and preserve historical
|
||||
meaning for displaced contributions rather than relabeling them as survivors.
|
||||
Test a retained lower record followed by an imported higher record, reordered
|
||||
matches, and nested helpers. Check version-evidence projection where applicable.
|
||||
|
||||
## 5. Honor Identity Reset On Actual Inheritance Replacement — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: inheritance resolves identity against actual child-value presence,
|
||||
discarding parent YAML identity for an explicit replacement while preserving
|
||||
agreement for retained values and authoritative application constraints. Added
|
||||
policy-state and public-load regressions for changed/unkeyed/non-sequence
|
||||
replacements, application agreement/conflict, and mode-only/append/upsert cases.
|
||||
Verified focused inheritance tests and `./scripts/check.sh`.
|
||||
|
||||
Owners: [`inheritanceCollectionResolution` and `mergeInheritanceCollectionPolicies`](../../internal/engine/inheritance_collection.go),
|
||||
especially lines 79–80 and 121.
|
||||
|
||||
These paths reconcile the parent's YAML identity before accounting for actual
|
||||
replacement, and treat the parent's collection as retained even when the child
|
||||
supplies a replacing value. This is stronger than the selected identity lifetime
|
||||
and differs from source-tier replacement.
|
||||
|
||||
Reproduction:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [base, items]
|
||||
key: id
|
||||
- destination: [child, items]
|
||||
key: name
|
||||
mode: replace
|
||||
base:
|
||||
items:
|
||||
- id: a
|
||||
value: parent
|
||||
child:
|
||||
extends: base
|
||||
items:
|
||||
- name: b
|
||||
value: child
|
||||
```
|
||||
|
||||
Loading fails with `sequence.conflict` at `/child/items`. It should succeed:
|
||||
actual whole-sequence replacement may discard the parent's YAML identity and
|
||||
establish `name`. Authoritative application constraints must still apply.
|
||||
|
||||
Required remediation: distinguish actual replacement from a mode-only declaration
|
||||
before identity agreement and transfer. Test a changed key, replacement with no
|
||||
new key becoming unkeyed, permitted non-sequence replacement, and an agreeing or
|
||||
conflicting application registration. Preserve rejection of identity changes
|
||||
without a replacement value and during append/upsert.
|
||||
|
||||
## 6. Enforce Collection Operations Beneath Newly Added Ancestors — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: copying a mapping now visits nested collection operations with absent
|
||||
lower values, preserving ordinary ancestor removal and whole-record semantics.
|
||||
Incoming record-owned policy transfers through the same operation. Regressions
|
||||
cover absent/empty/null ancestors, identity and kind failures, empty and valid
|
||||
initialization, and later inheritance using an imported item's nested identity.
|
||||
Corrected an older test that deferred the required error until a later tier.
|
||||
Verified focused merge/collection/inheritance tests, focused ownership race
|
||||
coverage, and `./scripts/check.sh`.
|
||||
|
||||
Owners: [`mergeRecursiveMappings`](../../internal/engine/recursive_merge.go) and
|
||||
[`mergeOverlayMappingsWithCollectionPolicy`](../../internal/engine/overlay.go).
|
||||
|
||||
When an incoming ancestor mapping is absent below, the recursive merger clones
|
||||
the entire subtree and invokes only its ancestor-level added hook. Nested
|
||||
collection resolutions are not visited. This bypasses their operation rules;
|
||||
the subsequent declaration-only policy update does not replace those checks.
|
||||
|
||||
Reproduction: combine a lower `{}` with this main tier:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [group, items]
|
||||
mode: append
|
||||
group:
|
||||
items:
|
||||
- id: a
|
||||
```
|
||||
|
||||
Loading succeeds despite append having no identity definition. Placing the same
|
||||
sequence directly at `[items]` correctly fails. The validity of an operation
|
||||
must not depend on whether an enclosing mapping already existed.
|
||||
|
||||
Required remediation: account for applicable nested collection policies when
|
||||
adding/copying an ancestor subtree, including owned-policy transfer. Test absent
|
||||
and present enclosing mappings, append/upsert without identity, explicit empty
|
||||
lists, invalid non-sequence contributions, and valid identified initialization.
|
||||
Do not turn this into a general path-existence or shape constraint: ordinary
|
||||
ancestor replacement/removal retains its documented meaning.
|
||||
|
||||
## 7. Validate Final Typed Identity Fields As Payload, Not Directives — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: shared identity lookup now distinguishes structural records from
|
||||
effective payload/reference mappings. Final validation includes literal `extends`
|
||||
fields while structural source checks still exclude actual directives. Facade
|
||||
regressions cover defaults and recorded resolution, string/null components, and
|
||||
duplicate rejection. Verified those regressions and existing structural identity
|
||||
and inheritance coverage through `./scripts/check.sh`.
|
||||
|
||||
Owners: [`validateFinalSequenceIdentities`](../../internal/engine/final_identity.go)
|
||||
and [`newSequenceMappingIndex`](../../internal/engine/sequence_identity.go).
|
||||
|
||||
The final validator reuses structural record lookup, which excludes every
|
||||
`extends` entry. At this phase there is no structural processing: typed defaults
|
||||
and application resolution can produce ordinary payload with that literal name.
|
||||
The roadmap expressly distinguishes structural-control exclusion from final
|
||||
typed-payload validation; this reuse introduces an extra restriction.
|
||||
|
||||
Reproduction: a defaults-only `Catalog` with `Items []map[string]any` containing
|
||||
`{"extends": "application-data"}`, registered with
|
||||
`SequenceIdentityAt(Keys("items"), "extends")`, fails with a missing identity
|
||||
field at `/items/0/extends`, even though the effective field is present.
|
||||
|
||||
Required remediation: distinguish structural-payload lookup from final payload
|
||||
lookup. Preserve rejection when a source tries to use an actual consumed
|
||||
directive as identity. Test final fields supplied by defaults and recorded
|
||||
resolution, including a null component, without adding a broader name ban.
|
||||
|
||||
## 8. Retain Nonfatal Main-Version Diagnostics In Layered Loads — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: layered assembly returns nonfatal main-version diagnostics on success
|
||||
and alongside later failures; the shared caller stops only for errors. Regression
|
||||
coverage verifies ordinary/layered warning parity, exactly one checker call, and
|
||||
warnings retained with later structural or decoding errors. Verified through
|
||||
`./scripts/check.sh`, including existing source-version ordering coverage.
|
||||
|
||||
Owner: [`assembleLayeredInput`](../../internal/engine/load_request.go), lines 143–152.
|
||||
|
||||
The moved default-mode version-check call returns its diagnostics only when they
|
||||
contain errors. Warning-only diagnostics are discarded. Ordinary loading still
|
||||
retains the same warnings.
|
||||
|
||||
Reproduction: a `VersionRule.Check` that calls
|
||||
`validation.Warning(Keys("version"), "test warning")` and returns nil yields one
|
||||
`version.warning` through ordinary `Load`, but zero diagnostics through
|
||||
`LoadLayered` with the same main input and default source-version mode.
|
||||
|
||||
Required remediation: carry warnings from the single main-tier check into the
|
||||
shared diagnostic accumulator on successful layered loads. Preserve checker
|
||||
ownership, invocation count, and the selected acquisition/composition ordering.
|
||||
Add ordinary/layered parity and warning-plus-later-error regressions.
|
||||
|
||||
## 9. Identify Conflicting Application Registrations In Diagnostics — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: definition validation retains original one-based registration
|
||||
positions and names both positions in each overlap diagnostic. No input path or
|
||||
new public diagnostic structure is invented. A mixed-selector regression verifies
|
||||
multiple conflicts even after an earlier invalid registration is skipped.
|
||||
Verified registration/facade coverage through `./scripts/check.sh`.
|
||||
|
||||
Owner: [`compileSequenceIdentityPolicy`](../../internal/engine/sequence_registration.go),
|
||||
lines 119–128.
|
||||
|
||||
Incompatible dynamic overlaps produce only a generic `options.invalid` message,
|
||||
an empty configuration path, and no related information. Neither registration
|
||||
is identified. The roadmap requires definition-time errors to identify the
|
||||
conflicting registrations without inventing a concrete input destination.
|
||||
|
||||
Reproduction: register `collections -> AnyMappingKey -> items` using `id`, and
|
||||
`collections -> foo -> items` using `name`. Even with `NoSource`, the error is
|
||||
only "overlapping sequence registrations must use the same identity fields".
|
||||
With multiple registrations the caller cannot identify the conflicting pair.
|
||||
|
||||
Required remediation: retain original registration positions and identify both
|
||||
in safe definition diagnostics, for example through positional message context.
|
||||
No new public diagnostic structure is required solely for this. Test mixed exact
|
||||
and dynamic registrations and multiple conflicts, without asserting complete
|
||||
English wording or exposing identity values.
|
||||
|
||||
## 10. Remove Stale Public Contract Descriptions — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: the public definition comment now describes final registered-identity
|
||||
validation and its shape/lifetime boundaries. Selected-overlay prose explicitly
|
||||
separates ordinary replacement from collection policy and links to the canonical
|
||||
operation/presence tables. Verified against the implementation, local-link tests,
|
||||
and `git diff --check`; no runtime behavior changed.
|
||||
|
||||
Owners: [`Definition.SequenceIdentities`](../../lifecycle.go), lines 18–21, and
|
||||
the [selected-overlay contract](../config.md#selected-overlays).
|
||||
|
||||
The exported definition-field comment still says registrations do not validate
|
||||
identities introduced by defaults, CLI, or callbacks. Final validation is now
|
||||
implemented and the registration constructors correctly document it.
|
||||
|
||||
The selected-overlay table and following paragraph also still unconditionally
|
||||
describe sequences as atomic replacements and `[]` as clearing. A later paragraph
|
||||
describes collection operations, but does not make the earlier unconditional
|
||||
claims accurate: identified collections infer upsert, and `[]` then contributes
|
||||
no records rather than clearing.
|
||||
|
||||
Required remediation: make the symbol comment describe its implemented final
|
||||
guarantee and boundaries, and qualify the overlay table/paragraph for collection
|
||||
policy. Link to the canonical collection tables rather than duplicating them.
|
||||
Keep YAML-only enforcement distinct from registered final validation.
|
||||
|
||||
## 11. Remove The Quadratic Prefix Scan In Item Policy Projection — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: assigned sequence ranges now supply item offsets directly. Handoffs
|
||||
without a range lazily accumulate prefix lengths, visiting each preceding
|
||||
contribution once rather than rescanning every prefix. Regression coverage keeps
|
||||
inline and policy-less contributions in the correct positions with complete,
|
||||
partial, or absent ranges. Existing ownership/placement tests remain intact.
|
||||
Verified focused item-policy tests, `./scripts/check.sh`, `go test -race ./...`,
|
||||
and inspection of the remaining error-only prefix-scan callers. No new benchmark
|
||||
fixture or performance threshold was needed for this bounded change.
|
||||
|
||||
Owner: [`projectNestedCollectionPolicies`](../../internal/engine/imports.go),
|
||||
especially line 968; the scanned helper is `priorSequenceLength` in the same file.
|
||||
|
||||
For every item with local collection policy, the loop scans all preceding
|
||||
contributions to calculate `itemIndex`, then overwrites that result when the
|
||||
already-indexed sequence range is available. For N such items this performs
|
||||
`0 + 1 + ... + (N-1)` contribution visits, excluding path lookup costs, despite
|
||||
the common case already having the exact assigned index. This is introduced
|
||||
quadratic work in the feature's independently authored-record path.
|
||||
|
||||
Required remediation: use the existing assigned range first. If a genuine
|
||||
fallback is necessary, maintain per-destination prefix lengths in one pass
|
||||
rather than rescanning every prefix. Preserve scope and ordering semantics;
|
||||
do not add limits, pooling, or a new public cache. Keep ownership/placement
|
||||
regressions and add a representative many-item/local-policy benchmark only if
|
||||
needed to exercise the path. A wall-clock threshold is not required.
|
||||
|
||||
## Integration Audit Baseline And Scope
|
||||
|
||||
Reviewed commit: `8d40983271de5239312eb0a2896737be88c5abdc`.
|
||||
|
||||
This follow-up reviews the interaction of collection policy, identified-record
|
||||
inheritance, dynamic registrations, and post-decode CLI assignment with existing
|
||||
source placement, source-version checking, typed decoding, inspection, resource
|
||||
contexts, and ownership boundaries. The accepted ADRs and current configuration
|
||||
contract remain the authority; the findings require integration corrections,
|
||||
not additional consumer restrictions or new feature choices.
|
||||
|
||||
Validation:
|
||||
|
||||
- `./scripts/check.sh` and `go test -race ./...` pass with Go 1.26.10 on Linux.
|
||||
- Isolated public-API probes outside the repository reproduce findings 12–15.
|
||||
They use labeled byte sources and an in-memory filesystem, with no external
|
||||
services, real credentials, or repository implementation changes.
|
||||
- Finding 16 follows from the per-item deep-copy loop; no timing claim or
|
||||
performance threshold is needed. Finding 17 follows from maintained contract
|
||||
text that contradicts the implemented collection operations.
|
||||
- Positive comparisons include sequence-valued identity replacement, ancestor
|
||||
retirement through source tiers, quoted versions after item movement, and an
|
||||
append overlay that preserves the main record's version.
|
||||
|
||||
## 12. Complete Identity Retirement Across Replacement Shapes — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: source composition and inheritance now prepare eligible lower policy
|
||||
from actual replacement paths before agreement. Removed identity and record state
|
||||
cannot constrain a new pending key or reappear in later inheritance. Shared mode
|
||||
selection preserves explicit-mode persistence and authoritative registrations.
|
||||
Regressions cover null/scalar/mapping/list replacement, ancestor removal with a
|
||||
new key, reintroduction, and retained/application-constrained conflicts. Verified
|
||||
focused retirement/inheritance tests, engine ownership race coverage, and
|
||||
`./scripts/check.sh`.
|
||||
|
||||
Owners: [`resolveStrictCollectionPolicies`](../../internal/engine/compose.go),
|
||||
especially its `sequencePresent` reset condition;
|
||||
[`mergeInheritanceCollectionPolicies`](../../internal/engine/inheritance_collection.go);
|
||||
and ancestor-replacement handling in
|
||||
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go).
|
||||
|
||||
Policy agreement and retirement still treat some actual replacements as retained
|
||||
collections. This has three observable effects under the same selected lifetime
|
||||
rule:
|
||||
|
||||
1. Source precedence accepts a replacement sequence with a new YAML key, but
|
||||
rejects the equivalent replacement by null or a scalar. For example, a lower
|
||||
tier declaring `key: id` for `[items]` with `items: [{id: old}]`, followed by
|
||||
a main tier declaring `key: name, mode: replace` with `items: null`, fails with
|
||||
`sequence.conflict` at `/items`. Replacing null with `42` also fails; replacing
|
||||
it with `[{name: new}]` succeeds. Null/scalar replacement should discard the
|
||||
lower identity and retain the newly declared key as pending policy, subject
|
||||
to the application's schema and constraints.
|
||||
2. Removing an ancestor while declaring a new pending key for its removed
|
||||
descendant fails before the replacement occurs. A lower
|
||||
`group: {items: [{id: old}]}` with key `id`, followed by `group: null` and a
|
||||
root declaration of key `name` for `[group, items]`, fails at `/group/items`.
|
||||
The analogous `base`/`child` inheritance case fails at `/child/group/items`.
|
||||
The old collection is being removed rather than retained.
|
||||
3. Inheritance can resurrect removed identity even without a conflicting new
|
||||
declaration. This input fails with a missing `id` at
|
||||
`/grandchild/group/items/0/id`:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences: [{destination: [base, group, items], key: id}]
|
||||
base: {group: {items: [{id: old}]}}
|
||||
child:
|
||||
extends: base
|
||||
group: null
|
||||
grandchild:
|
||||
extends: child
|
||||
group: {items: [{name: new}]}
|
||||
```
|
||||
|
||||
The same remove/reintroduce operations through source tiers succeed. The
|
||||
inherited descendant identity remains in policy state after its enclosing
|
||||
mapping is removed, so the grandchild incorrectly receives the old key.
|
||||
|
||||
Required remediation: account for actual value and ancestor replacement before
|
||||
deciding which lower identity participates in agreement, and retire removed
|
||||
descendant/record-owned state during inheritance as during source precedence.
|
||||
Retained inheritance templates must not reestablish discarded identity later.
|
||||
Preserve authoritative application constraints, independently owned mode
|
||||
persistence, pending declarations, and rejection of key changes when values are
|
||||
genuinely retained. Cover direct non-sequence replacement, ancestor removal with
|
||||
a new pending key, and removal followed by reintroduction across both contexts.
|
||||
Do not require repeated keys or narrow permitted null/scalar shapes to avoid
|
||||
these cases.
|
||||
|
||||
## 13. Keep Metadata History In Post-Decode CLI Order — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: metadata history is assembled from the pre-CLI source view, with CLI
|
||||
events appended separately; inheritance is projected before CLI and resolution.
|
||||
Effective fields still describe post-CLI values. Regressions verify chronological
|
||||
origins and resolver dependencies for ordinary/layered mapping and record
|
||||
inheritance, including equal-valued assignments. Verified history/override
|
||||
coverage and `./scripts/check.sh`.
|
||||
|
||||
Owners: [`historyWithInheritance`](../../internal/engine/inheritance_history.go)
|
||||
and the source/CLI metadata handoff in
|
||||
[`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go).
|
||||
|
||||
The value explanation follows the new lifecycle, but `Metadata.History` still
|
||||
inserts inheritance after stored CLI changes. `historyWithInheritance` splits
|
||||
only at `LayerResolution`, reflecting the former CLI-before-inheritance order.
|
||||
The reconstructed source view can also append an authored source origin after
|
||||
the CLI event.
|
||||
|
||||
Reproduction: load `base: {value: parent}` and
|
||||
`child: {extends: base, value: authored}`, then override `[child, value]` with
|
||||
`"cli"`. The final value and `Metadata.Lookup` correctly report CLI provenance.
|
||||
`Explain` reports source → inheritance → CLI. `Metadata.History` instead reports
|
||||
source → CLI → source → inheritance and ends with a source/inheritance origin.
|
||||
This violates chronological metadata history and disagrees with the effective
|
||||
origin and the value explanation.
|
||||
|
||||
Required remediation: assemble metadata history in the implemented source,
|
||||
inheritance, CLI, and resolution order, preserving the real origins and
|
||||
dependencies of those events. Cover ordinary/layered loading, mapping and record
|
||||
inheritance, equal-valued CLI writes, and a later recorded resolver change at the
|
||||
appropriate existing test owners. Do not impose a new exact event-count or
|
||||
message-format contract.
|
||||
|
||||
## 14. Carry Main-Version Coordinates Through Sequence Movement — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: the effective version rule now follows the main declaration through
|
||||
strict placement, tier combination, and inheritance without changing source-local
|
||||
checker/evidence paths. Underlay and overlay ownership checks use assigned record
|
||||
positions, including whole-record removal; integer conversion remains confined
|
||||
to the actual main header. Regressions cover mounted/indexed paths, appended and
|
||||
matched records, integer/string parity, unrelated version payload, imported
|
||||
underlay claims, and inheritance. Corrected an older ownership fixture to target
|
||||
the actual matching main record. Verified source-version regressions,
|
||||
`./scripts/check.sh`, and `go test -race ./internal/engine`.
|
||||
|
||||
Owners: [`effectiveSourceVersionRuleForPlacement`](../../internal/engine/source_version.go),
|
||||
the main-tier projection handoff in
|
||||
[`assembleLayeredInput`](../../internal/engine/load_request.go), and use of
|
||||
the effective rule in [`executeLoadRequest`](../../internal/engine/load.go).
|
||||
|
||||
Source destinations and version-evidence sensitivity paths follow item movement,
|
||||
but the rule used for integer-version decoding retains the original item index.
|
||||
Consequently an accepted integer header can fail decoding after an otherwise
|
||||
valid upsert placement.
|
||||
|
||||
Reproduction with the public API:
|
||||
|
||||
- Main input is `id: main` and `version: 4`, placed as one sequence item at
|
||||
`Keys("items")`.
|
||||
- A lower input contributes the sequence item `id: lower` at the same destination.
|
||||
- Register identity `id` for `[items]`, select `SourceVersionModeIfPresent`, and
|
||||
use `VersionRule.Path = Keys("version")` with an accepting checker.
|
||||
- Decode into records with `ID string` and `Version string`.
|
||||
|
||||
The checker accepts `4` exactly once. Upsert places the main record at index 1,
|
||||
but decoding fails with `decode.type` at `/items/1/version`. The integer-to-string
|
||||
exception is still tied to `/items/0/version`. Quoting the identical version as
|
||||
`'4'` succeeds. A source-local indexed header path, without item placement,
|
||||
reproduces the same problem when its record moves during upsert.
|
||||
|
||||
Required remediation: carry the main declaration's effective coordinates through
|
||||
the existing item projections into decoding and audit the associated ownership
|
||||
protection handoffs for the same stale-coordinate assumption. Preserve
|
||||
source-local checker/evidence paths, checker invocation count, main-only
|
||||
ownership, and the narrow integer-to-string exception. Cover moved existing and
|
||||
newly appended main records, integer/string parity, and protection of the actual
|
||||
main header after movement. Do not prohibit supported indexed paths or mounted
|
||||
main records as a workaround.
|
||||
|
||||
## 15. Exclude Parent Identity From Inherited History Payload — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: retained record-inheritance operations now exclude parent identity
|
||||
from their payload and origin projection. Replay preserves the child's own
|
||||
identity before taking field, record, sequence, or root snapshots, without
|
||||
inventing inheritance events for retained identity fields. Regressions cover
|
||||
single and compound identities, explicit child provenance, ordinary inherited
|
||||
payload, and the parent's original history. Verified inheritance/inspection
|
||||
coverage, `./scripts/check.sh`, and `go test -race ./internal/engine`.
|
||||
|
||||
Owners: inheritance operation construction in
|
||||
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go),
|
||||
[`appendInheritanceExplanation`](../../internal/engine/inheritance_explain.go),
|
||||
and transferred-origin projection in
|
||||
[`inheritance_history.go`](../../internal/engine/inheritance_history.go).
|
||||
|
||||
Identified-record value construction excludes the parent's identity fields, but
|
||||
the retained history operation still supplies the complete parent record and
|
||||
its identity origins to the older mapping-inheritance replay machinery.
|
||||
|
||||
Reproduction:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences: [{destination: [items], key: id}]
|
||||
items:
|
||||
- id: base
|
||||
value: parent
|
||||
- id: child
|
||||
extends: {id: base}
|
||||
```
|
||||
|
||||
The effective child ID and origin are correct. However,
|
||||
`Explain(Keys("items").Index(1).Key("id"))` reports `child` → `base` → `child`,
|
||||
attributing the intermediate value to an inheritance operation and the parent's
|
||||
ID location. `Metadata.History` likewise claims an inherited parent-ID
|
||||
contribution. That identity is expressly excluded from inherited payload; it was
|
||||
never an applied value at the child path.
|
||||
|
||||
Required remediation: make history replay and transferred-origin projection use
|
||||
the same identity exclusion as value construction. Preserve the child's authored
|
||||
identity and presence, ordinary inherited payload history, and the parent record's
|
||||
own source history at its original path. Cover compound identities and identity
|
||||
fields alongside inherited payload at both record and ancestor queries; keep the
|
||||
existing inspection API and sensitivity behavior.
|
||||
|
||||
## 16. Avoid Deep-Copying Accumulated Item Policies Per Record — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: item projection now takes one owned policy copy per destination,
|
||||
updates that local state incrementally, and stores it once after projection.
|
||||
Incoming scopes still cross detached ownership boundaries. Inspection confirms
|
||||
that accumulated record scopes are no longer deep-copied per item; no timing
|
||||
threshold or benchmark campaign was needed. Existing stable-index, sparse-range,
|
||||
fallback-offset, and ownership coverage passes, with a new regression for
|
||||
interleaved destinations, distinct record owners, and sibling/cross-load mutation
|
||||
independence. Verified focused item-policy tests, `./scripts/check.sh`, and
|
||||
`go test -race ./...`.
|
||||
|
||||
Owners: [`projectNestedCollectionPolicies`](../../internal/engine/imports.go)
|
||||
and its use of copying `collectionPolicyScope.at` / `set` helpers in
|
||||
[`collection_policy_state.go`](../../internal/engine/collection_policy_state.go).
|
||||
|
||||
The prefix-scan correction in finding 11 is present, but a separate quadratic
|
||||
cost remains on the same independently authored-record path. For every imported
|
||||
item with local policy, `at` deep-clones the destination's accumulated record
|
||||
order and every retained record's nested scope. After adding one owner, `set`
|
||||
deep-clones the growing policy again. With N fixed-size item-local policies at
|
||||
one destination, this copies approximately `1 + 2 + ... + N` retained scopes,
|
||||
even when every assigned sequence range is available.
|
||||
|
||||
This follows directly from the loop and `cloneCollectionPolicy`; no busy-host
|
||||
timing comparison is required. It is an integration cost from reusing detached
|
||||
read/write helpers within private incremental construction, rather than from
|
||||
the required final ownership copies.
|
||||
|
||||
Required remediation: construct each destination's owned record-policy state
|
||||
without repeatedly cloning all earlier records, retaining necessary copies at
|
||||
ownership boundaries. Preserve record ordering, distinct owners, sparse assigned
|
||||
positions, fallback offsets, and cross-load detachment. Reuse the existing item
|
||||
policy and ownership tests; add allocation/scaling evidence only where useful.
|
||||
Do not introduce public caches, pooling, new caps, or performance thresholds.
|
||||
|
||||
## 17. Reconcile Legacy Sequence-History Contract Text — P2
|
||||
|
||||
Status: [x] Complete.
|
||||
|
||||
Completion: metadata and explanation prose now describe history through the
|
||||
selected collection operation, assigned item positions, and displaced payload.
|
||||
The text distinguishes sequence snapshots and atomic diffs from append/upsert
|
||||
combination and links to the canonical operation tables. Whole-record upsert,
|
||||
existing queries, and sensitivity guarantees are unchanged. Verified against
|
||||
replay/combination code, local documentation-link tests, and `git diff --check`;
|
||||
the unchanged runtime is covered by the preceding everyday and full race checks.
|
||||
|
||||
Owner: the metadata/provenance and explanation sections of
|
||||
[`docs/config.md`](../config.md#layered-metadata).
|
||||
|
||||
The metadata section still says that a later tier supplying a sequence records
|
||||
replacement at the sequence path and that history does not imply identity-based
|
||||
merging across tiers. That describes the former unconditional replacement model,
|
||||
not the implemented append/upsert history or the collection operation tables in
|
||||
the same document. The explanation paragraph about atomic sequences also needs
|
||||
review so readers can distinguish snapshot/diff granularity from collection
|
||||
combination semantics.
|
||||
|
||||
Required remediation: describe history for the selected collection operation and
|
||||
link to the canonical operation tables. Preserve the existing atomic sequence
|
||||
snapshot/diff behavior and whole-record upsert semantics; correcting the prose
|
||||
does not authorize recursive record-field merging, a new query API, or broader
|
||||
history guarantees.
|
||||
|
||||
## Algorithm Audit Baseline And Scope
|
||||
|
||||
Reviewed commit: `c73daf891d0f29c96f7026bdabcccb571f44a751`.
|
||||
Feature baseline: `4f941f8ba4c9a9ed0afa87b07fe6ba29f131d1c6`.
|
||||
|
||||
This audit examines the sequence identity/operation, scoped-policy, inheritance,
|
||||
inspection, and post-decode CLI algorithms, including the completed corrections
|
||||
above. The intervening discovery roadmap commits add no implementation to audit.
|
||||
The accepted scheduler, deterministic ordering, exact numeric normalization,
|
||||
selector trie, and deliberately pairwise registration-overlap validation remain
|
||||
selected design choices; they are not findings here.
|
||||
|
||||
Evidence and validation:
|
||||
|
||||
- Code inspection establishes the complexity of findings 19–23 on bounded-depth
|
||||
inputs with linear-sized results. Required result/history copies and genuinely
|
||||
growing inheritance output are excluded from the alleged excess work. Bounds
|
||||
describe internal operations, not new public performance guarantees.
|
||||
- An isolated public-API probe reproduces finding 18 using an in-memory
|
||||
filesystem, without external services or real credentials.
|
||||
- Temporary tests supplied through a Go overlay, outside the repository, checked
|
||||
all 768 combinations of append/upsert/replace over the 16 unique-ID lists drawn
|
||||
from three IDs. Stable ordering, duplicate rejection, and whole-record payload
|
||||
replacement agreed with an independent reference calculation.
|
||||
- A structural overlay test confirmed that 4, 8, and 16 one-item contributions
|
||||
retain 10, 36, and 136 reverse-index cells respectively in `indexSequenceRanges`.
|
||||
These are representation counts, not timing or real-world benchmark results.
|
||||
- `go test ./internal/engine -run 'Sequence|Collection|Inheritance|Override|SourceVersion|Metadata|History' -count=1`
|
||||
passed with Go 1.26.10 on Linux. Passing existing tests does not cover the
|
||||
incorrect selector scope or establish efficient growth.
|
||||
|
||||
The following are focused corrections within the selected model. This review
|
||||
changes only the remediation record.
|
||||
|
||||
## 18. Match Application Registrations In Effective Item Coordinates — P2
|
||||
|
||||
Status: [ ] Open.
|
||||
|
||||
Owner: `composePendingItems` in
|
||||
[`imports.go`](../../internal/engine/imports.go), especially its local
|
||||
`composeDocumentsWithCollectionPolicy` call; the current
|
||||
`TestPendingItemCompositionAppliesApplicationIdentityConstraints` in
|
||||
[`item_policy_test.go`](../../internal/engine/item_policy_test.go) also needs review.
|
||||
|
||||
Pending item/helper documents are composed in item-local coordinates while the
|
||||
unchanged global application selector policy is supplied to their composer.
|
||||
A registration for root `[prompts]` therefore also constrains an unrelated
|
||||
`[items, 0, prompts]` collection before placement. This contradicts complete,
|
||||
effective-destination matching and makes an otherwise unused registration reject
|
||||
valid configuration. Application selectors do not traverse sequence indexes.
|
||||
|
||||
Reproduction: main imports `item.yml` with destination `[items]` and placement
|
||||
`sequence-item`. The item contains `id: outer` and
|
||||
`prompts: [{text: payload}]`. Loading succeeds without a registration; adding
|
||||
only `SequenceIdentityAt(Keys("prompts"), "id")` fails with `compose.invalid`
|
||||
at `/items/0/prompts/0/id`, although no root `prompts` collection exists.
|
||||
|
||||
Required remediation: separate item-local YAML policy from application matching
|
||||
in effective coordinates, including nested helper scopes. Preserve applicable
|
||||
outer collection constraints without reinterpreting global selectors as local
|
||||
ones. Add ordinary/layered regressions for unused root and dynamic selectors
|
||||
alongside imported item-local collections. Correct tests that pin the accidental
|
||||
scope; do not change selector meaning or require consumers to remove valid rules.
|
||||
|
||||
## 19. Avoid Dense Global-Position Storage For Sparse Item Projections — P2
|
||||
|
||||
Status: [ ] Open.
|
||||
|
||||
Owners: `newSequenceItemProjectionIndex` in
|
||||
[`sequence_projection.go`](../../internal/engine/sequence_projection.go),
|
||||
`indexSequenceRange` / `projectedCompositionOrigins` in
|
||||
[`compose.go`](../../internal/engine/compose.go), and contribution indexing in
|
||||
[`history.go`](../../internal/engine/history.go).
|
||||
|
||||
The reverse projection slice is sized to `maxOutput + 1`, even for one incoming
|
||||
item assigned to a late assembled position. `indexSequenceRange` materializes
|
||||
these indexes for ordinary contiguous append ranges too. With N one-item sources
|
||||
contributing to one sequence, reverse slices retain `1 + 2 + ... + N` cells.
|
||||
Initialization time and simultaneously retained auxiliary storage are therefore
|
||||
quadratic despite only N actual mappings. Origin/history projection constructs
|
||||
the same representation through additional callers. Finding 16's policy-copy
|
||||
correction does not remove this independent cost.
|
||||
|
||||
Required remediation: retain arithmetic start/end lookup for contiguous ranges
|
||||
and use storage proportional to actual mappings for scattered positions, such as
|
||||
a sparse reverse map. Audit shared callers and avoid rebuilding equivalent indexes
|
||||
inside item queries. Preserve bidirectional lookup, absent positions, upsert
|
||||
replacement evidence, ordering, and detached results. Verify the representation
|
||||
on many one-item sources and noncontiguous upserts; add no timing threshold or
|
||||
collection-size limit.
|
||||
|
||||
## 20. Remove Full Metadata Scans Per CLI Replacement Leaf — P2
|
||||
|
||||
Status: [ ] Open.
|
||||
|
||||
Owners: `updateOverridePresence`, `deletePresencePathSubtree`, and
|
||||
`updateOverrideOrigins` in
|
||||
[`override_decode.go`](../../internal/engine/override_decode.go).
|
||||
|
||||
Every replaced scalar or subtree scans all presence keys, then all origin keys,
|
||||
to find matching prefixes. A single partial-mapping override updating N scalar
|
||||
children in an N-field configuration performs quadratic map-entry visits. This
|
||||
does not require N CLI sources or growing historical output: the same effective
|
||||
maps remain approximately N entries throughout the update.
|
||||
|
||||
Required remediation: delete affected entries by walking the already known
|
||||
existing subtree, or use one batch-local prefix index where needed. Retain
|
||||
necessary copies at ownership boundaries without rescanning unrelated paths for
|
||||
each leaf. Preserve retained explicit zero/false values, removal of replaced
|
||||
descendants, null/kind changes, origin attribution, and detached loads. Use the
|
||||
existing nested CLI and ownership regressions plus focused broad-mapping coverage;
|
||||
do not change override semantics or impose batch limits.
|
||||
|
||||
## 21. Localize Inheritance Policy Work To The Relevant Scope — P2
|
||||
|
||||
Status: [ ] Open.
|
||||
|
||||
Owners: `inheritancePolicyScopeAtPath` in
|
||||
[`inheritance.go`](../../internal/engine/inheritance.go), graph preparation in
|
||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go),
|
||||
and policy handoff/retirement in
|
||||
[`inheritance_walk.go`](../../internal/engine/inheritance_walk.go).
|
||||
|
||||
Scope localization scans every entry in the supplied policy table for each
|
||||
definition. Mapping graph preparation also clones the full supplied table, and
|
||||
the walker passes its global table to each independent scope and scans that table
|
||||
again during replacement. For S scopes or siblings and P unrelated collection
|
||||
policies, these paths perform repeated S-by-P work and copies.
|
||||
|
||||
For example, N independent mapping groups can each contain a constant-sized
|
||||
`base`/`child extends base` pair and one YAML collection declaration beneath its
|
||||
base. Input, expanded values, and relationship evidence remain linear in N, but
|
||||
each group's graph/localization visits or clones the other groups' policies.
|
||||
This is separate from necessary expansion along a genuinely growing parent chain
|
||||
and from the selected dependency scheduler.
|
||||
|
||||
Required remediation: partition or index policies by semantic scope once and
|
||||
carry the relevant state through traversal, including efficient descendant
|
||||
retirement as inheritance introduces or removes destinations. Clone owned
|
||||
in-scope state rather than unrelated global state. Preserve record owners,
|
||||
pending declarations, mode lifetime, destination-local registration matching,
|
||||
and independent templates. Verify wide independent groups and nested item scopes;
|
||||
do not replace the agreed scheduling algorithm or add global caches.
|
||||
|
||||
## 22. Index Fallback Location Lookup During Effective Projection — P2
|
||||
|
||||
Status: [ ] Open.
|
||||
|
||||
Owner: `documentWithMetadataLocations` in
|
||||
[`override_context.go`](../../internal/engine/override_context.go), using
|
||||
`nearestDocumentLocation` in
|
||||
[`validation.go`](../../internal/engine/validation.go).
|
||||
|
||||
The new effective-projection walk calls the unindexed nearest-location helper
|
||||
for each field lacking a real origin location, often once for its mapping entry
|
||||
and again for its value. Each call starts at the fallback root and linearly scans
|
||||
mapping entries. With N authored root fields and N different default-only root
|
||||
fields, each default's failed lookup scans all N authored keys: quadratic work
|
||||
for linear-sized input and output. The shared lifecycle calls this path even
|
||||
without CLI assignments or a resolver.
|
||||
|
||||
Required remediation: share an indexed fallback lookup or traverse corresponding
|
||||
containers together while carrying the nearest available location. Existing
|
||||
`documentLookup` / mapping-index machinery can support bounded-depth lookups
|
||||
without repeated wide scans. Preserve nearest-ancestor fallback, source IDs,
|
||||
synthetic locations, secret-reference diagnostics, and publication ownership.
|
||||
Exercise mixed authored/default-only broad mappings; do not weaken attribution
|
||||
or skip needed location attachment to avoid the work.
|
||||
|
||||
## 23. Index Repeated Collection-Policy Document Lookups — P2
|
||||
|
||||
Status: [ ] Open.
|
||||
|
||||
Owners: `mergeOverlayMappingsWithCollectionPolicy` in
|
||||
[`overlay.go`](../../internal/engine/overlay.go) and
|
||||
`mergeInheritanceCollectionPolicies` in
|
||||
[`inheritance_collection.go`](../../internal/engine/inheritance_collection.go).
|
||||
|
||||
These routines loop over collection policies/declarations and call plain
|
||||
`documentAtPath` for each destination. That helper linearly searches every
|
||||
mapping on the path. N independent collections below N sibling mapping keys
|
||||
therefore cause quadratic mapping-entry searches when checking which lower
|
||||
policies the incoming tier touches. The inheritance parent/child lookups and
|
||||
retained-value declaration checks have the same pattern. YAML declarations alone
|
||||
can establish these policies; the deliberately pairwise application-registration
|
||||
overlap check is not involved.
|
||||
|
||||
Required remediation: reuse a document lookup built once for each stable input
|
||||
tree, or use the already collected claims where their scope is sufficient.
|
||||
Index a mutated result only after its value-changing phase, rather than retaining
|
||||
a stale lookup. Preserve absent destinations, non-sequence pending declarations,
|
||||
literal key/index distinctions, retirement, and error locations. Verify broad
|
||||
independent collections across precedence and inheritance; no new public cache,
|
||||
shape requirement, or performance gate is needed.
|
||||
@@ -1,998 +0,0 @@
|
||||
# Sequence Identity And Combination Roadmap
|
||||
|
||||
Status: settled feature roadmap; implementation is specified in the
|
||||
[staged implementation plan](implementation.md). This document defines the
|
||||
targeted end state, not an implemented contract. The implementation plan owns
|
||||
execution order, exact Go names and signatures, bookkeeping, and validation.
|
||||
|
||||
## Purpose And Scope
|
||||
|
||||
Allow configuration authors to assemble collections from independently authored
|
||||
records, declare identity in configuration, and progressively extend a baseline
|
||||
collection through higher layers. Authors can replace the entire collection,
|
||||
append distinct records, or replace complete records by identity while retaining
|
||||
unmatched records. Sibling inheritance uses the same sequence operations and
|
||||
identity-based defaults. Within an identified sequence, a record can explicitly
|
||||
inherit settings from another record in that sequence while keeping its own
|
||||
identity.
|
||||
|
||||
The motivating use case is a prompt catalog whose source-backed baseline supplies
|
||||
records and whose application or installation layers add or supersede records.
|
||||
Applications can optionally impose identity constraints that configuration must
|
||||
respect and that hold in the final loaded configuration. These constraints can
|
||||
cover exact destinations or collections beneath dynamically named mapping keys,
|
||||
without requiring the application to discover those names before loading.
|
||||
|
||||
The feature covers ordinary and layered loading, imported items, inline
|
||||
sequences, selected overlays, and sibling inheritance. Typed defaults, CLI
|
||||
assignments, and application resolution retain their own combination semantics;
|
||||
CLI application moves later in the loading lifecycle.
|
||||
|
||||
Automatic recursive merging of matching records remains deferred
|
||||
(see [future work](future.md#1-recursive-body-merging-for-identified-records));
|
||||
explicit inheritance between distinct records is included. The initial
|
||||
declaration surface is `configkit.sequences`; `key` and `mode` shorthand on
|
||||
imports is deferred. Application lists remain
|
||||
ordinary YAML sequences without control wrappers. Unkeyed sequence concatenation
|
||||
is outside this feature.
|
||||
|
||||
## Current Baseline And Required Changes
|
||||
|
||||
The [import contract](../config.md#explicit-imports) owns existing selectors,
|
||||
destinations, placement, and ordering. `placement: sequence-item` already places
|
||||
each selected source mapping as one item at its effective destination. Item-local
|
||||
helper imports can contribute fields before placement.
|
||||
|
||||
The [composition contract](../config.md#strict-document-composition),
|
||||
[layered-loading contract](../config.md#layered-loading), and
|
||||
[inheritance contract](../config.md#sibling-inheritance) describe the implemented
|
||||
baseline:
|
||||
|
||||
| Area | Current behavior | Target behavior |
|
||||
| --- | --- | --- |
|
||||
| Identity declaration | `Definition.SequenceCompositions` registers one literal string field at each registered mapping path. | YAML collection declarations and optional authoritative application registrations support field sets and scalar identity components. |
|
||||
| Application registration destinations | Registrations select exact literal mapping paths. | Exact and structural selectors can impose identity beneath arbitrary mapping keys, independently at each matched destination. |
|
||||
| Strict composition | Registered sequences append distinct string identities and reject duplicates; repeated unregistered sequences conflict. | Collections with effective identity append distinct records and reject duplicates. Applicable lower-tier identity is available before composing higher-tier peers. Unidentified sequences retain strict-conflict behavior. |
|
||||
| Source precedence and selected overlays | A present higher sequence replaces the lower sequence wholesale. | Collection policy selects `replace`, `append`, or `upsert`; overlays can declare policy for inline sequences. |
|
||||
| Sibling inheritance | An inheriting mapping recursively overlays its resolved parent; supplied sequences replace wholesale. | Mappings retain recursive combination, while sequences use destination policy and the same three operations. |
|
||||
| Sequence-member inheritance | A sequence element has no sibling scope for its own inheritance relationship. | An identified sequence provides a sibling scope: a record can inherit from one record in the same sequence, selected by complete identity. |
|
||||
| CLI overrides | Apply before inheritance and typed defaults/decoding. | Apply after inheritance and typed defaults/decoding, before application resolution. |
|
||||
| Identity enforcement | Registration governs strict sequence composition. | Structural identity checks cover identified combinations; application registrations additionally constrain final effective sequences. |
|
||||
|
||||
Source-backed underlays participate in source assembly. `Definition.Defaults`
|
||||
provides typed defaults later; extending source combination does not turn typed
|
||||
default lists into source underlays.
|
||||
|
||||
## Collection Declarations
|
||||
|
||||
Use a source-root `configkit.sequences` block containing collection declarations.
|
||||
Each entry identifies a `destination` and may declare `key`, `mode`, or both.
|
||||
Identity and mode can be supplied independently. This form serves imported items,
|
||||
inline records, ordinary mapping imports, and inline sequences in selected
|
||||
overlays.
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: [namespace, id]
|
||||
mode: append
|
||||
imports:
|
||||
- dir: prompts
|
||||
pattern: '*.yaml'
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
```
|
||||
|
||||
Each selected file contributes a record such as:
|
||||
|
||||
```yaml
|
||||
namespace: support
|
||||
id: welcome
|
||||
text: Welcome
|
||||
```
|
||||
|
||||
Here append retains a lower-layer collection and adds distinct higher-layer
|
||||
records. Identity and combination controls belong to the collection declaration,
|
||||
so imported and inline contributions share one policy surface.
|
||||
|
||||
### Destinations And Scope
|
||||
|
||||
`destination` uses the existing sequence of literal mapping keys. Resolve it
|
||||
relative to the declaring source's placement: a fragment mounted under `catalog`
|
||||
can declare `[prompts]` locally. A sequence-item source can declare a nested
|
||||
collection within its own item without referring to an eventual numeric index.
|
||||
Preserve source and item-local scope boundaries.
|
||||
|
||||
A collection's scope is its resolved destination within its composition scope.
|
||||
Resolve relative destinations before reconciling declarations. Different imported
|
||||
items retain separate scopes for their nested collections. A declaration governs
|
||||
all contributions to its collection in the tier, including imported and inline
|
||||
records; it does not govern only records in the declaring file.
|
||||
|
||||
Gather declarations before composing the relevant scope so their effect does not
|
||||
depend on which contribution is assembled first. Retain applicable controls
|
||||
through source precedence and inheritance, then keep them out of application
|
||||
decoding.
|
||||
|
||||
Sequence declarations are independent of filesystem import enablement. Selected
|
||||
overlays may contain `configkit.sequences`, extending the current blanket
|
||||
prohibition on overlay `configkit` declarations. This does not enable imports
|
||||
in overlays.
|
||||
|
||||
### Overlay Authoring
|
||||
|
||||
For an existing sequence `[foo, bar]`, an overlay can replace `foo` in full:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: upsert
|
||||
prompts:
|
||||
- id: foo
|
||||
text: Revised definition
|
||||
```
|
||||
|
||||
The result is `[foo*, bar]`. The explicit upsert mode can be omitted when no
|
||||
different explicit mode applies from a lower tier: effective identity then
|
||||
selects upsert. To add a distinct record instead:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: append
|
||||
prompts:
|
||||
- id: baz
|
||||
text: Another prompt
|
||||
```
|
||||
|
||||
The result is `[foo, bar, baz]`. To replace the whole sequence:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
mode: replace
|
||||
prompts:
|
||||
- id: bar
|
||||
text: Bar definition
|
||||
- id: baz
|
||||
text: Baz definition
|
||||
```
|
||||
|
||||
The result is `[bar, baz]`. Explicitly selecting the default operation is valid.
|
||||
With no applicable explicit mode, omitting this declaration selects replace for
|
||||
an unidentified collection and upsert for an identified one. A retained explicit
|
||||
mode continues to govern.
|
||||
|
||||
## Identity Definitions And Constraints
|
||||
|
||||
### Field Sets
|
||||
|
||||
`key` accepts a string or a nonempty list of strings naming direct, literal
|
||||
fields in each record. A name such as `metadata.id` denotes that literal field,
|
||||
not a nested lookup. Treat field names as a set: remove repeated names and
|
||||
ignore authored order when comparing definitions.
|
||||
|
||||
Thus `id`, `[id]`, and `[id, id]` are equivalent; `[namespace, id]`,
|
||||
`[id, namespace]`, and `[namespace, id, id]` are also equivalent. An empty list
|
||||
is invalid because it supplies no identity fields. These rules apply to both
|
||||
configuration declarations and application registrations.
|
||||
|
||||
Use a consistent field order internally to construct identity tuples. Preserve
|
||||
component types and tuple boundaries so unstructured concatenation cannot cause
|
||||
otherwise distinct identities to collide.
|
||||
|
||||
### Scalar Components And Equality
|
||||
|
||||
Each identified record must be a mapping with every designated identity field
|
||||
present. Components may be strings, numbers, booleans, or explicit null. Missing
|
||||
fields and mapping or sequence components are invalid. Present null differs
|
||||
from absence; two single-field null identities are equal. Compound identities
|
||||
are equal when all corresponding components are equal.
|
||||
|
||||
Strings compare exactly, including empty strings, without trimming, case folding,
|
||||
or normalization. Scalar categories remain distinct: `"1"`, `1`, `true`, and
|
||||
null are different identities.
|
||||
|
||||
Numbers compare by mathematical value across integer, fractional, and exponent
|
||||
spellings: `1`, `1.0`, and `1e0` are equal, as are `0` and `-0`. Use exact numeric
|
||||
normalization for identity comparison without rounding through a floating-point
|
||||
conversion. This does not rewrite authored values or change existing typed
|
||||
decoding and projection semantics. The parser's rejection of non-finite numeric
|
||||
forms remains unchanged.
|
||||
|
||||
### Application-Owned Constraints
|
||||
|
||||
Retain an optional application interface for registering collections and imposing
|
||||
their identity definitions. Configuration may define identity where no application
|
||||
constraint exists. When a declaration omits `key`, an applicable registration
|
||||
supplies identity. An explicit equivalent definition is accepted, and a different
|
||||
definition fails under every mode, including replace.
|
||||
|
||||
For example, if the application requires `id`, `key: [id, name]` is invalid:
|
||||
that compound identity would allow repeated `id` values with different names.
|
||||
A registration is an authoritative constraint, not an omission-filling default.
|
||||
Copying a collection through inheritance does not create another application
|
||||
registration; registrations constrain their registered destinations.
|
||||
|
||||
Identity constraints validate actual sequences. They do not independently require
|
||||
a path to exist, be non-null, or have sequence shape. The application schema and
|
||||
validation own those requirements. Final-result enforcement for application
|
||||
registrations is defined under [Validation And Diagnostics](#validation-and-diagnostics).
|
||||
|
||||
### Application Registration API
|
||||
|
||||
Use one public sequence-identity registration type, collected in one field on
|
||||
`Definition`. Each registration contains an explicit destination selector and
|
||||
its identity fields. Exact and dynamic registrations share matching, overlap
|
||||
validation, identity comparison, and lifecycle behavior.
|
||||
|
||||
Provide two constructors returning that same registration type:
|
||||
|
||||
- An exact-path convenience constructor accepting a literal `Path` and identity
|
||||
field names. It converts the path into a selector containing literal segments.
|
||||
- An explicit-selector constructor accepting a structural selector and identity
|
||||
field names, for destinations that include arbitrary mapping keys.
|
||||
|
||||
Both constructors accept identity fields as variadic strings, so one-field and
|
||||
compound identities use the same construction form. Apply the settled field-set
|
||||
rules, including deduplication. Invalid registrations, including an empty identity
|
||||
field set, fail during application-definition validation.
|
||||
|
||||
Existing `Path` values retain their literal meaning throughout ConfigKit. The
|
||||
exact-path constructor supplies convenience at the registration boundary; dynamic
|
||||
matching uses the explicit selector type. Both forms can appear together in the
|
||||
same definition field.
|
||||
|
||||
Replace the current registration shape with this model. Preserving the existing
|
||||
`SequenceComposition` fields and `Definition.SequenceCompositions` spelling is not
|
||||
a requirement, and no compatibility facade is needed solely to retain that syntax.
|
||||
Choose names centered on sequence identity, reflecting its role in composition,
|
||||
inheritance, and final validation. Exact Go type, field, and constructor names
|
||||
and signatures belong in the implementation plan.
|
||||
|
||||
### Dynamic Application Registration Destinations
|
||||
|
||||
Allow applications to register identity for collections whose containing mapping
|
||||
keys become known only during loading. For example:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
weatherreporter:
|
||||
prompts:
|
||||
- id: foo.bar
|
||||
text: Report the weather.
|
||||
homewatch:
|
||||
prompts:
|
||||
- id: foo.bar
|
||||
text: Report the household status.
|
||||
```
|
||||
|
||||
One application registration can require `id` for every namespace's `prompts`
|
||||
list. The equal IDs above are valid because each list has its own uniqueness
|
||||
scope. An optional root `prompts` list can have a separate exact registration.
|
||||
|
||||
Use an explicit application-side structural selector composed of literal mapping
|
||||
keys and an arbitrary-mapping-key segment. Conceptually, the namespace rule is:
|
||||
|
||||
```text
|
||||
Literal("collections") -> AnyMappingKey -> Literal("prompts")
|
||||
```
|
||||
|
||||
This notation explains the selector; it is not a Go signature or YAML syntax.
|
||||
Each segment consumes exactly one mapping level. Match the complete destination,
|
||||
not an arbitrary prefix or suffix. Literal keys remain exact, including empty
|
||||
strings and keys containing punctuation such as `*`, `/`, or `.`. An arbitrary-key
|
||||
segment is distinct from any literal spelling and does not match a sequence
|
||||
index. Existing `Path` values retain their literal meaning.
|
||||
|
||||
This capability belongs to the reusable application definition and applies to
|
||||
ordinary and layered loads. YAML collection destinations and import destinations
|
||||
retain their existing literal syntax. Recursive search, regular expressions,
|
||||
filesystem glob rules, sequence-index selectors, and custom matching callbacks
|
||||
are outside this addition.
|
||||
|
||||
At each concrete matched destination, apply the same authoritative identity
|
||||
constraint as an exact registration. Use the settled field-set and scalar-equality
|
||||
rules. Do not pool identities across destinations or require records to repeat
|
||||
the containing mapping key as an identity component. Applications retain ownership
|
||||
of namespace interpretation, qualified runtime IDs, and domain validation.
|
||||
|
||||
Matching supplies effective identity before relevant strict composition and
|
||||
precedence operations. Without an applicable explicit mode, this identity selects
|
||||
upsert under the existing default rule. It also supplies identity for inheritance
|
||||
within the matched sequence. YAML declarations must agree with it under every
|
||||
mode, including replace. These are ordinary application constraints with a broader
|
||||
destination selector, not a separate final-validation facility.
|
||||
|
||||
### Registration Overlap
|
||||
|
||||
Equivalent application constraints coalesce at any shared destination. Equivalence
|
||||
uses the identity field-set rules: for example, `id` and `[id, id]` agree.
|
||||
Incompatible constraints fail; exact registrations do not override broader
|
||||
selectors, and registration order grants no precedence.
|
||||
|
||||
Detect incompatible overlapping registrations while validating the application
|
||||
definition, before source acquisition. For these fixed-depth mapping selectors,
|
||||
overlap exists when the selectors have the same number of segments and every
|
||||
corresponding pair can match a common key. Unequal literal keys at any position
|
||||
make the selectors disjoint; an arbitrary-key segment can overlap a literal or
|
||||
another arbitrary-key segment. Compare segment structure rather than rendered
|
||||
path strings. No configuration pre-read is needed.
|
||||
|
||||
Thus an `id` rule for all namespace prompt lists conflicts with a `[id, name]`
|
||||
rule for one named namespace, even if that namespace is absent from a particular
|
||||
load. This is an invalid application definition. A valid selector matching no
|
||||
collections is harmless: registration does not require a path to exist, create
|
||||
collections, or impose shape and nullability requirements.
|
||||
|
||||
### Matching Across The Loading Lifecycle
|
||||
|
||||
Match application selectors against effective configuration destinations after
|
||||
accounting for input placement. Physical filenames and source-local paths do not
|
||||
determine applicability. Make constraints available before composing contributions
|
||||
at those destinations, including namespaces first introduced by imports, mounted
|
||||
inputs, or higher tiers. Apply matching constraints to key-only YAML declarations
|
||||
as well, even before their sequence exists. Consumers need neither a preliminary
|
||||
load nor source inspection to enumerate namespaces.
|
||||
|
||||
During inheritance, apply registrations at the inheriting destination, including
|
||||
newly created matching destinations. Copying a collection does not transfer its
|
||||
application registrations to a different path. The existing YAML-policy ownership
|
||||
and transfer rules remain separate from application selector matching.
|
||||
|
||||
During final validation, find matching actual sequences in the final effective
|
||||
configuration. This includes namespaces introduced or replaced by typed defaults,
|
||||
CLI overrides, and application resolution. Reusing only destinations found during
|
||||
source assembly would miss these values. Use the same selector meaning and
|
||||
identity validator in both phases; later assignments do not trigger composition
|
||||
or inheritance again. YAML-only identities retain their existing enforcement
|
||||
boundary.
|
||||
|
||||
## Strict Composition And Precedence Operations
|
||||
|
||||
### Compose Peers Before Applying Precedence
|
||||
|
||||
Resolve applicable identity and mode policy, strictly compose the tier's peer
|
||||
contributions, then apply one selected operation per collection against the
|
||||
accumulated lower value. The tier operation does not sequentially replace peer
|
||||
source contributions. Selected overlay contributions use the same combination
|
||||
semantics.
|
||||
|
||||
Within a strict tier, equivalent identity definitions coalesce and different
|
||||
field sets fail. Agreeing explicit modes coalesce; conflicting explicit modes
|
||||
fail. Omission expresses no preference. A root declaration or a later declaration
|
||||
has no precedence over conflicting peers. Explicitly selecting a default mode
|
||||
is still a real instruction and can conflict with another explicit mode.
|
||||
|
||||
Identified peer sequences append distinct records. Duplicate identities among
|
||||
peers or within a contributed list fail under all three modes, including replace
|
||||
and upsert. Equal or disjoint record payloads do not excuse a duplicate identity.
|
||||
Unidentified sequences retain their current strict-composition conflict behavior.
|
||||
|
||||
For example, higher-tier fragments `[C]` and `[D]` with an agreed identity assemble
|
||||
as `[C, D]`. Against lower `[A, B]`, append or upsert produces `[A, B, C, D]`,
|
||||
while replace produces `[C, D]`. Peers `[B]` and `[B*]` conflict even under
|
||||
upsert because neither has higher precedence.
|
||||
|
||||
### Available Operations
|
||||
|
||||
Use one spelling for each mode: `replace`, `append`, and `upsert`.
|
||||
|
||||
| Mode | Behavior at source-precedence and inheritance boundaries |
|
||||
| --- | --- |
|
||||
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
|
||||
| `append` | Retain lower records and append higher records; duplicate identities fail. |
|
||||
| `upsert` | Replace matching records in full, retain unmatched lower records, and append new identities. |
|
||||
|
||||
Append and upsert require effective identity. ConfigKit does not infer identity
|
||||
from an `id` field or record position. Whole-sequence replacement does not require
|
||||
identity agreement with lower YAML policy, but application constraints still apply.
|
||||
|
||||
Letters denote record identities; `B*` has the same identity as `B` with a different
|
||||
payload:
|
||||
|
||||
| Lower | Higher | Mode | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| `[A, B]` | `[C]` | `append` | `[A, B, C]` |
|
||||
| `[A, B]` | `[C]` | `replace` | `[C]` |
|
||||
| `[A, B]` | `[B*]` | `append` | Duplicate-identity error |
|
||||
| `[A, B]` | `[B*]` | `replace` | `[B*]` |
|
||||
| `[A, B]` | `[B*]` | `upsert` | `[A, B*]` |
|
||||
| `[A, B]` | `[B*, C]` | `upsert` | `[A, B*, C]` |
|
||||
|
||||
Upsert replaces the whole matched record. If lower `B` has `id`, `text`, and
|
||||
`temperature`, while higher `B` has only `id` and `text`, the result at this
|
||||
boundary has only `id` and `text`. Subsequent defaulting or other lifecycle
|
||||
operations retain their own semantics. Mappings continue to combine recursively;
|
||||
matching sequence records do not recursively merge fields.
|
||||
|
||||
### Mode Selection And Persistence
|
||||
|
||||
Select the operation in this order:
|
||||
|
||||
1. Reconcile explicit modes within the current tier; conflicting peers fail.
|
||||
2. Use the current tier's explicit mode if supplied.
|
||||
3. Otherwise retain an applicable explicit mode from a lower tier.
|
||||
4. With no explicit mode, infer upsert when effective identity is available.
|
||||
5. Otherwise infer replace.
|
||||
|
||||
Effective identity can come from an application registration, current declarations,
|
||||
or applicable lower policy, subject to the identity agreement and lifetime rules
|
||||
below. Determine the inferred mode from available identity before applying the
|
||||
operation. A plain higher-tier sequence can therefore use upsert without repeating
|
||||
a key declaration.
|
||||
|
||||
An explicit mode persists at its applicable destination until a higher-priority
|
||||
explicit declaration changes it. New sequence values alone do not reset it, and
|
||||
an unrelated overlay leaves it intact. A mode-only declaration can change policy
|
||||
without supplying records; it neither clears the collection nor retroactively
|
||||
recombines earlier tiers. Later source contributions and inheritance use the
|
||||
resulting policy.
|
||||
|
||||
| Lower explicit mode | Higher declaration | Effective mode |
|
||||
| --- | --- | --- |
|
||||
| `append` | Mode omitted | `append` |
|
||||
| `append` | `upsert` | `upsert` |
|
||||
| `upsert` | `replace` | `replace` |
|
||||
| None | Mode omitted, effective identity available | Inferred `upsert` |
|
||||
| None | Mode omitted, no effective identity | Inferred `replace` |
|
||||
|
||||
Only explicit modes persist. Inferred defaults are recomputed from effective
|
||||
identity when needed. Explicit replace continues to apply in subsequent tiers
|
||||
until another mode is declared; introducing identity alone does not override it.
|
||||
Mode persistence does not preserve YAML identity discarded by replacement, nor
|
||||
policies owned by discarded records. Source precedence and inheritance use the
|
||||
same destination policy, without separate per-phase settings. CLI assignments
|
||||
remain outside mode selection.
|
||||
|
||||
### Presence, Empty Sequences, Null, And Kind Mismatches
|
||||
|
||||
At a collection's precedence boundary:
|
||||
|
||||
| Higher contribution | `replace` | `append` / `upsert` |
|
||||
| --- | --- | --- |
|
||||
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
|
||||
| Explicit `[]` | Clear the sequence. | Contribute no records. |
|
||||
| Explicit null or another non-sequence value | Ordinary replacement, subject to application schema. | Error: the operation requires a sequence. |
|
||||
|
||||
A declaration alone does not clear a collection. An optional import selecting
|
||||
nothing contributes nothing and does not erase existing records. An absent lower
|
||||
collection can be initialized by an incoming sequence under any mode, subject to
|
||||
identity requirements. Explicit lower null or another non-sequence value is not
|
||||
absence: append and upsert fail against it, while replacement remains available.
|
||||
An empty incoming sequence bypasses neither kind checks nor identity requirements.
|
||||
|
||||
With effective identity and no explicit mode, `prompts: []` uses upsert and does
|
||||
not clear the collection. Clearing requires replace, selected in the current tier
|
||||
or retained from lower policy. Replacing an identified collection with null also
|
||||
requires replace and a schema permitting null. Collection null is distinct from
|
||||
a present null identity component inside a record.
|
||||
|
||||
### Ordering
|
||||
|
||||
Retain existing dependency-ready source scheduling within each tier and authored
|
||||
item order within lists. Gathering declarations does not change assembly order;
|
||||
identity values do not sort records.
|
||||
|
||||
- Append keeps lower records in order, followed by higher records in assembled order.
|
||||
- Replace keeps only higher records in their assembled order.
|
||||
- Upsert keeps a matched record's lower position and appends new identities in
|
||||
higher-tier order.
|
||||
|
||||
For example, lower `[A, B, C]` and higher `[C*, A*, D]` produce `[A*, B, C*, D]`
|
||||
under upsert. Whole-sequence replacement gives the higher tier control of the
|
||||
complete sequence order.
|
||||
|
||||
## Policy Agreement, Lifetime, And Ownership
|
||||
|
||||
### Identity Agreement And Replacement
|
||||
|
||||
Append and upsert use one agreed identity for all participating records:
|
||||
|
||||
| Lower identity | Higher declaration | Result when combining records |
|
||||
| --- | --- | --- |
|
||||
| `id` | Omitted | Use `id`. |
|
||||
| `id` | `id`, `[id]`, or `[id, id]` | Accept the equivalent definition. |
|
||||
| `id` | `[namespace, id]` | Error: different definitions. |
|
||||
| None | `id` | Establish `id` and validate all participating records. |
|
||||
|
||||
Introducing identity for an unkeyed lower sequence requires checking both lower
|
||||
and incoming records, including uniqueness within each contribution. With no
|
||||
identity from the application or either participating collection, append and
|
||||
upsert fail.
|
||||
|
||||
YAML identity follows the collection that survives an operation:
|
||||
|
||||
| Operation | Effective YAML identity |
|
||||
| --- | --- |
|
||||
| Append or upsert | Retain or establish the agreed identity. |
|
||||
| Whole-sequence replacement | Discard lower YAML identity and use the replacing tier's identity declaration, if any. |
|
||||
| Replacement by null or another non-sequence value | Discard lower YAML identity and retain any identity explicitly declared by the replacing tier. |
|
||||
|
||||
Application constraints remain authoritative under every operation. Without an
|
||||
application constraint, replacement can introduce a different identity, such as
|
||||
changing from `id` to `name`. Replacing a sequence without a new key declaration
|
||||
or application registration produces an unkeyed sequence. No identity-removal
|
||||
directive is needed.
|
||||
|
||||
The reset occurs only when the collection value is actually replaced. Declaring
|
||||
replace without supplying a replacement does not authorize changing the identity
|
||||
of retained records. Explicit mode lifetime is separate from YAML identity
|
||||
lifetime.
|
||||
|
||||
### Ancestor Replacement
|
||||
|
||||
Replacing an enclosing mapping can remove a collection without replacing that
|
||||
collection directly. Apply the same identity and ownership rules to this removal:
|
||||
discard the removed collection's lower YAML identity, and discard policies owned
|
||||
by any removed records. Reintroducing the same path later does not restore those
|
||||
discarded policies.
|
||||
|
||||
Independently declared explicit modes retain their existing persistence rules.
|
||||
Removing a descendant collection does not remove a mode whose declaring scope
|
||||
survives. Application registrations remain applicable at matching destinations,
|
||||
including destinations reintroduced later. Identity newly declared by the
|
||||
replacing tier follows the existing declaration and ownership rules.
|
||||
|
||||
For example, a root-level declaration establishes `key: id` and `mode: append`
|
||||
for `[catalog, prompts]`. A higher tier replaces `catalog` with null, removing
|
||||
the prompts collection and its YAML identity. The independently declared append
|
||||
mode survives. If a later tier recreates `catalog.prompts`, append requires
|
||||
identity supplied by a new declaration or an application registration. Without
|
||||
that identity, loading fails; the later tier can instead explicitly choose
|
||||
replace. The replacement of `catalog` itself follows ordinary ancestor-value
|
||||
precedence and does not attempt to append null to the removed prompts list.
|
||||
|
||||
### Declarations Without Sequence Values
|
||||
|
||||
A key declaration without accompanying records applies immediately to an existing
|
||||
retained sequence: check agreement and validate its records, without recombining
|
||||
earlier tiers. For example, a higher tier that only declares
|
||||
`destination: [prompts], key: id` identifies and validates the retained lower
|
||||
`prompts` sequence.
|
||||
|
||||
If no collection exists, retain the declaration in its owning scope for later
|
||||
contributions. An otherwise empty underlay can establish identity for main-tier
|
||||
fragments. An explicit identity declaration can also remain pending at a
|
||||
non-sequence destination; it does not independently impose a shape requirement.
|
||||
The presence and kind rules still determine whether an attempted operation fails.
|
||||
Item-local declarations retain their ownership boundaries.
|
||||
|
||||
### Lower-Tier Identity During Strict Composition
|
||||
|
||||
Resolve applicable identity before composing a higher tier's peers, using
|
||||
application registrations, available lower-tier policy, and declarations gathered
|
||||
within the higher tier's scope. Authors need not repeat a baseline key solely
|
||||
because the higher tier has multiple fragments.
|
||||
|
||||
For example, an underlay establishes `key: id` for `prompts`, and two main-tier
|
||||
fragments contribute `[foo]` and `[bar]` without a key declaration. They strictly
|
||||
compose as `[foo, bar]` before the main tier combines with the accumulated lower
|
||||
collection. If both contribute `foo`, composition fails; upsert does not give
|
||||
precedence to peers.
|
||||
|
||||
Lower YAML identity remains subject to agreement, replacement, and ownership.
|
||||
A replacing collection must not be constrained by obsolete lower identity just
|
||||
because policy resolution precedes composition. Likewise, a replacement record
|
||||
cannot borrow nested identity owned by the discarded lower record, even when
|
||||
outer identity or numeric position matches. Application constraints still apply.
|
||||
Higher-tier declarations do not retroactively repair invalid lower-tier composition.
|
||||
|
||||
### Nested Record Ownership
|
||||
|
||||
Attach item-local policies to their owning contributions so they follow surviving
|
||||
records independently of numeric indexes. Whole-record upsert discards the old
|
||||
record's local policies along with its fields. Whole-sequence replacement discards
|
||||
policies owned by removed records. A replacement record supplies its own local
|
||||
declarations; equal outer identity or final index does not transfer discarded
|
||||
policies.
|
||||
|
||||
For example, a source imported as one record into `prompts` can declare:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [steps]
|
||||
key: id
|
||||
mode: append
|
||||
id: foo
|
||||
steps:
|
||||
- id: prepare
|
||||
```
|
||||
|
||||
The declaration governs `steps` inside that `foo` record. Retaining `foo` retains
|
||||
its local policy, including the explicit mode. Upserting a new `foo` discards the
|
||||
old `steps` and its declaration even though the outer record keeps its position.
|
||||
|
||||
Policies independently applicable at a destination are not discarded merely
|
||||
because an individual record is replaced. Ownership determines which declarations
|
||||
are removed. Application registrations remain constraints at their registered
|
||||
destinations. Inheritance copies transfer identity and choose operations as
|
||||
specified next.
|
||||
|
||||
## Sibling Inheritance
|
||||
|
||||
Complete source-tier assembly before resolving inheritance. Retain existing
|
||||
mapping-sibling scopes and add identified-sequence scopes as described below.
|
||||
Both use the existing dependency-ready algorithm. At each inheritance combination,
|
||||
the resolved parent's collection is the lower value and the inheriting mapping's
|
||||
local collection is the higher value. Mappings combine recursively; upsert within
|
||||
an identified sequence remains whole-record replacement. Explicit record
|
||||
inheritance is a separate construction step after those replacements.
|
||||
|
||||
Use the inheriting destination collection's policy: an applicable explicit mode,
|
||||
including one retained through source tiers, wins. Otherwise infer upsert with
|
||||
effective identity and replace without it. There is no separate default-append
|
||||
rule for inheritance. The same destination policy governs source-precedence and
|
||||
inheritance combinations.
|
||||
|
||||
YAML identity follows a collection copied through inheritance, subject to agreement
|
||||
and replacement rules. A source collection's mode does not automatically become an
|
||||
instruction at a different inheriting destination. For example, source `steps`
|
||||
has `key: id` and `mode: append`; an inheriting mapping supplies local `steps`
|
||||
without an applicable explicit mode. The copied identity selects upsert there.
|
||||
An explicit destination append instead selects append. A matching destination key
|
||||
agrees; a different key conflicts when combining records, while explicit replace
|
||||
can establish new YAML identity subject to application constraints.
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [parent, items]
|
||||
key: id
|
||||
child:
|
||||
items:
|
||||
- id: foo
|
||||
value: A
|
||||
- id: bar
|
||||
value: B
|
||||
parent:
|
||||
extends: child
|
||||
items:
|
||||
- id: baz
|
||||
value: C
|
||||
```
|
||||
|
||||
Despite the names, `parent` inherits from `child`. Identity at `[parent, items]`
|
||||
selects upsert, producing `[foo, bar, baz]`. If the local sequence instead supplies
|
||||
a revised `foo`, the result is `[foo*, bar]`. Append rejects that repeated identity;
|
||||
replace retains only the local sequence.
|
||||
|
||||
The same ordering, presence, and kind rules apply at inheritance boundaries.
|
||||
An omitted local sequence retains the inherited sequence. Explicit `[]` clears it
|
||||
under replace and contributes no records under append or upsert. Retain controls
|
||||
until inheritance uses them, then exclude them from application decoding.
|
||||
|
||||
### Inheritance Between Identified Sequence Records
|
||||
|
||||
An identified sequence supplies a sibling scope for its records. Identity can
|
||||
come from an application registration or an applicable YAML declaration. A record
|
||||
may inherit from one other record in that same sequence using an `extends`
|
||||
mapping that selects the parent's complete identity:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
prompts:
|
||||
- id: base
|
||||
model: standard
|
||||
temperature: 0.5
|
||||
- id: concise
|
||||
extends: {id: base}
|
||||
temperature: 0.2
|
||||
```
|
||||
|
||||
The final `concise` record is:
|
||||
|
||||
```yaml
|
||||
id: concise
|
||||
model: standard
|
||||
temperature: 0.2
|
||||
```
|
||||
|
||||
The `base` record remains in the collection. Parents are ordinary records subject
|
||||
to the same decoding and validation as other records; there is no abstract
|
||||
template category.
|
||||
|
||||
Use mapping-form references for both single and compound keys. With
|
||||
`key: [namespace, id]`, a reference can be
|
||||
`extends: {namespace: shared, id: base}`. Match components using the collection's
|
||||
field-set and scalar-equality rules, including mathematical numeric equality.
|
||||
`extends: {id: null}` selects a null-valued identity; `extends: null` cancels a
|
||||
relationship.
|
||||
|
||||
Parent-reference mappings use ordinary strict mapping composition. Item-local
|
||||
helper imports may supply disjoint fields of a compound reference; overlapping
|
||||
fields conflict, even when equal. For example, a helper's
|
||||
`extends: {namespace: shared}` and the importing record's `extends: {id: base}`
|
||||
compose into `extends: {namespace: shared, id: base}`. This assembles one record;
|
||||
it does not permit strict peers to contribute duplicate record identities.
|
||||
|
||||
Before parent lookup, require the surviving effective reference mapping to contain
|
||||
exactly the collection's identity fields, with supported scalar values. Missing
|
||||
or extra fields fail. Validate the completed reference during inheritance, rather
|
||||
than requiring each helper contribution to be independently complete. Superseded
|
||||
references remain historical evidence; earlier strict-composition conflicts
|
||||
remain errors.
|
||||
|
||||
Every record must supply its complete own identity before inheritance. Preserve
|
||||
the child's identity fields and inherit only the parent's nonidentity payload;
|
||||
inheritance cannot supply a missing child identity. Child payload fields take
|
||||
precedence, mappings combine recursively, and nested sequences use the inheriting
|
||||
destination's collection policy. Consume the record's directive before decoding.
|
||||
|
||||
Support one parent within the same identified sequence, including forward
|
||||
references and chains. Missing parents and cycles, including self-reference,
|
||||
fail the load. Cross-collection references, position-based references, multiple
|
||||
parents, and inheritance between unidentified sequence members are outside this
|
||||
scope. Existing string-form references between mapping siblings retain their
|
||||
meaning; they do not become shorthand for sequence identity references.
|
||||
|
||||
Resolve relationships against records surviving source-tier assembly and selected
|
||||
overlays. A higher layer can replace a parent before inheritance runs; removing
|
||||
that parent makes a surviving reference fail. Whole-record upsert discards the
|
||||
old record's directive along with its payload. Omitting `extends` in the
|
||||
replacement record does not retain the displaced record's relationship. An
|
||||
explicit reference to the record's own identity is a cycle, not a way to access
|
||||
its lower-tier predecessor.
|
||||
|
||||
Reuse Kahn's algorithm with the existing stable canonical semantic-pointer key
|
||||
for dependency-ready ordering. Build parent lookup from the sequence's effective
|
||||
identity, and preserve the assembled list order independently of resolution
|
||||
order. Enclosing relationships resolve before nested relationships. Parent
|
||||
templates retain nested instructions for evaluation at each inheriting
|
||||
destination, following the existing destination-local inheritance rule. Copied
|
||||
collection identity, destination operation selection, and record-owned policy
|
||||
transfer follow the rules above.
|
||||
|
||||
Record inheritance runs in the existing structural phase, before typed defaults,
|
||||
decoding, CLI overrides, and application resolution. Those later stages do not
|
||||
trigger another inheritance pass. Preserve inherited presence, origin and
|
||||
history evidence, sensitivity, declaring resource contexts, and existing
|
||||
expansion budgets just as for mapping-sibling inheritance. Identity lookup must
|
||||
not expose identity values in diagnostics. Existing index-based inspection paths
|
||||
remain sufficient; no new identity-selector path API is needed.
|
||||
|
||||
## Loading Lifecycle
|
||||
|
||||
Use the following order:
|
||||
|
||||
1. Assemble source tiers and selected overlays, resolving policy before each
|
||||
tier's strict composition and applying collection operations at precedence
|
||||
boundaries.
|
||||
2. Resolve sibling inheritance in mappings and identified sequences, then apply
|
||||
typed defaults and decode the assembled configuration.
|
||||
3. Apply explicit CLI overrides to that configuration and check supplied values
|
||||
against the application schema.
|
||||
4. Run application resolution against the overridden configuration.
|
||||
5. Resolve effective secret references, validate the final configuration including
|
||||
application-registered identities, and publish it.
|
||||
|
||||
### Typed Defaults And Application Resolution
|
||||
|
||||
Source-backed underlays, including application-supplied mapping sources,
|
||||
participate in sequence combination. Typed `Definition.Defaults` remains the
|
||||
initial decoded state: supplied sequences replace default sequences. Source
|
||||
append does not append to a typed default list.
|
||||
|
||||
Application resolution retains assignment and recorded-transformation semantics.
|
||||
ConfigKit does not automatically combine a callback's assigned sequence with its
|
||||
previous value. Application-registered identities receive final validation
|
||||
independently of how defaults, CLI overrides, or callbacks produce their values.
|
||||
|
||||
### CLI Precedence
|
||||
|
||||
CLI overrides are the highest-precedence input to application resolution.
|
||||
Supplied sequences replace whole sequences, including sequences inside supplied
|
||||
mappings. Mappings retain recursive override behavior; empty sequences and
|
||||
permitted nulls retain explicit replacement meaning. Configuration append or
|
||||
upsert does not reinterpret CLI assignments. Inheritance and defaulting do not
|
||||
run again afterward: overriding assembled `[A, B]` with `[C]` presents `[C]` to
|
||||
application resolution without inheritance reintroducing prior records.
|
||||
|
||||
Application resolution sees CLI values and their explicit-presence and source
|
||||
metadata. `Resolution.Default` preserves explicit CLI values; normalization and
|
||||
recorded application transformations may still change them. For example, a derived
|
||||
URL can use a CLI hostname, while an explicit CLI URL can be preserved by an
|
||||
absence-based default. Retain recording and provenance requirements for those
|
||||
transformations.
|
||||
|
||||
Reject structural directives such as `extends` and `configkit` in CLI override
|
||||
paths or supplied values. CLI input supplies application values after structural
|
||||
processing; it cannot supply or cancel inheritance directives, change completed
|
||||
relationships, or prevent earlier inheritance failures.
|
||||
|
||||
Typed decoding also precedes CLI application, so a CLI replacement cannot repair
|
||||
an earlier decoding failure. A source string in an integer field fails before a
|
||||
valid CLI integer can apply. This changes current override-before-decoding
|
||||
behavior. Semantic validation remains after overrides and application resolution.
|
||||
|
||||
Secret lookup uses effective references after overrides and resolution. Neither
|
||||
stage bypasses final application identity constraints or semantic validation.
|
||||
|
||||
## Validation And Diagnostics
|
||||
|
||||
### Structural Identity Checks
|
||||
|
||||
Allow item-local helper imports to complete and consume applicable non-main
|
||||
source-version headers before checking payload identity for collection assembly.
|
||||
A helper can supply an identity component; a consumed header cannot supply one
|
||||
after removal.
|
||||
|
||||
Structural controls are likewise unavailable as source identity components.
|
||||
Check identity against application payload fields, excluding fields acting as
|
||||
structural directives even when the engine retains them for later processing.
|
||||
For example, `key: extends` with an item containing `extends: null` does not
|
||||
supply a null identity: that field cancels inheritance and is consumed. Report
|
||||
the ordinary missing-identity-component error at the collection/item path using
|
||||
available origins, without exposing values. Do not defer this failure until
|
||||
directive consumption or final application validation.
|
||||
|
||||
This rule follows a field's structural role; it does not introduce a broader
|
||||
restriction on unrelated literal field names. Final application identity checks
|
||||
continue to examine the effective typed payload under their existing rules.
|
||||
|
||||
Validate each identified contribution before combination and check uniqueness in
|
||||
the resulting identified collection, including inheritance operations. Upsert
|
||||
matches across precedence are replacements; duplicates within a contribution
|
||||
remain errors. Later inheritance, typed defaults, or callbacks cannot repair an
|
||||
identity missing at an earlier assembly boundary.
|
||||
|
||||
YAML-only identities are enforced during structural composition, precedence
|
||||
assembly, and identified inheritance operations. They have no final-result
|
||||
guarantee after typed defaulting, CLI overrides, or application resolution. Those
|
||||
stages can produce duplicate identities or otherwise violate a YAML-only identity.
|
||||
Applications needing a final guarantee register the constraint through the
|
||||
application-owned interface.
|
||||
|
||||
Adding record inheritance does not expand this guarantee: a YAML declaration
|
||||
can enable parent lookup without imposing final validation on values later
|
||||
produced by defaults, CLI overrides, or application resolution.
|
||||
|
||||
### Final Application Constraints
|
||||
|
||||
On successful loading, final effective collections covered by application
|
||||
registrations satisfy their declared identity constraints. Retain assembly-time
|
||||
checks and validate final effective sequences before publication, after defaults,
|
||||
inheritance, overrides, application resolution, and secret lookup.
|
||||
|
||||
This covers both exact and dynamic application registrations. Evaluate dynamic
|
||||
selectors against the final effective configuration, with independent validation
|
||||
for each concrete matched sequence as described under
|
||||
[Matching Across The Loading Lifecycle](#matching-across-the-loading-lifecycle).
|
||||
|
||||
ConfigKit performs these identity checks; consumers register the constraint
|
||||
without needing to repeat duplicate detection in `Definition.Validate`.
|
||||
Application-owned semantic validation continues to cover domain-specific rules.
|
||||
|
||||
This guarantee covers actual sequences, not path existence or allowed shape.
|
||||
It does not require every intermediate callback state to satisfy the constraint
|
||||
and does not cover caller mutations after loading returns. Final checks examine
|
||||
effective typed values; assembly checks validate authored contributions. A final
|
||||
scalar alone does not prove that a source explicitly supplied that field.
|
||||
|
||||
Use the existing canonical final-value representation and shared publication
|
||||
path as the foundation. The guarantee requires neither a new mutation-history
|
||||
system nor exact causal reconstruction for records supplied by defaults or
|
||||
callbacks.
|
||||
|
||||
### Diagnostics And Inspection
|
||||
|
||||
Identity diagnostics identify collection and item paths using available origin
|
||||
information without exposing identity values. Conflicting declarations identify
|
||||
the collection destination and declaration locations.
|
||||
|
||||
For dynamic registrations, use concrete matched collection and item paths for
|
||||
value diagnostics, with available origins and existing sensitivity rules.
|
||||
Definition-time overlap errors identify the conflicting registrations; they do
|
||||
not require or invent a concrete destination from a loaded document.
|
||||
|
||||
Correctness includes final values, projected item paths, source destinations,
|
||||
provenance, history, sensitivity, and passive resource contexts. Appending can
|
||||
change final indexes; replacing changes which contributions survive. Preserve
|
||||
the existing loading and inspection contracts through both operations, including
|
||||
policy ownership and inheritance transfer.
|
||||
|
||||
## Work Needed From The Baseline
|
||||
|
||||
The baseline needs the following connected capabilities to reach the target
|
||||
state. Execution order and test sequencing belong to the
|
||||
[implementation plan](implementation.md):
|
||||
|
||||
- **Declarations and public API:** extend application registration to the settled
|
||||
identity model with one public registration type and definition field, an
|
||||
exact-path convenience constructor, and an explicit-selector constructor.
|
||||
Preserve literal `Path` semantics. Parse and consume `configkit.sequences` across
|
||||
supported source forms and selected overlays; preserve placement-relative and
|
||||
item-local scopes. Keep sequence declarations independent of filesystem import
|
||||
enablement.
|
||||
- **Identity and policy representation:** implement field-set comparison, typed
|
||||
scalar identity tuples with exact numeric equality, declaration agreement,
|
||||
pending declarations, explicit-mode persistence, replacement lifetime, and
|
||||
record-owned policy transfer. Choose bookkeeping proportional to these semantics.
|
||||
Handle descendant collection removal through ancestor replacement, preserving
|
||||
independently declared modes and application constraints while discarding
|
||||
removed collection identity and record-owned policies.
|
||||
- **Registration matching:** support literal and arbitrary-mapping-key segments,
|
||||
detect incompatible selector overlap during definition validation, and resolve
|
||||
applicable constraints at concrete placed destinations. Reuse the identity
|
||||
rules and validator for exact and dynamic registrations. Keep matching separate
|
||||
from source discovery and from YAML declaration ownership.
|
||||
- **Assembly handoffs:** make accumulated lower policy available before higher-tier
|
||||
strict composition. Separate source acquisition from policy-aware composition
|
||||
or otherwise revise assembly sequencing; current main-before-underlay composition
|
||||
is not a design constraint. Preserve distinctions among acquisition order,
|
||||
dependency-ready scheduling within tiers, and precedence order. Account for
|
||||
source records, callbacks, main-version checks, and failure diagnostics. Retain
|
||||
version ownership and validation semantics; higher replacement cannot hide
|
||||
lower-tier structural failures.
|
||||
- **Combination and inheritance:** implement the three operations with strict peer
|
||||
handling, whole-record replacement, presence rules, stable ordering, and the
|
||||
destination policy rules for inheritance. Extend sibling-scope discovery and
|
||||
parent lookup to identified sequences, validate mapping-form references, and
|
||||
preserve child identities while copying parent payloads. Reuse the shared
|
||||
dependency-ready scheduler and destination-local nested resolution. Preserve
|
||||
provenance, history, sensitivity, paths, and resource contexts as records survive,
|
||||
move, or disappear.
|
||||
- **Lifecycle and validation:** move CLI application after inheritance and typed
|
||||
defaults/decoding, preserve explicit-presence and source metadata for application
|
||||
resolution, reject CLI structural directives, and add final application identity
|
||||
validation through the shared publication path. Keep YAML-only enforcement
|
||||
within structural operations. Match dynamic registrations during assembly and
|
||||
inheritance, and against the final effective configuration rather than a saved
|
||||
list of source-backed destinations.
|
||||
Exclude structural controls from source identity checks even while retaining
|
||||
those controls for later inheritance processing.
|
||||
- **Documentation and verification:** exercise ordinary and layered loading,
|
||||
imported and inline collections, item-local scopes, version-header consumption,
|
||||
selected overlays, inheritance, typed defaults, CLI, callbacks, and inspection.
|
||||
Cover single and compound parent references, scalar equality, forward references
|
||||
and chains, missing parents and cycles, parent replacement/removal, child identity
|
||||
preservation, nested destination policy, cancellation, and stable output order.
|
||||
Verify helper-composed references, overlapping reference fields, missing or extra
|
||||
fields, and replacement of references together with their containing records.
|
||||
Verify independent namespace uniqueness, root and namespaced registrations,
|
||||
literal punctuation keys, equivalent and incompatible overlaps, and conflicts
|
||||
detected even without a matching input destination. Cover namespaces introduced
|
||||
by imports, placement, higher tiers, inheritance, defaults, CLI, and callbacks;
|
||||
preserve ordinary strict-peer and upsert behavior. Check YAML disagreement under
|
||||
replace, harmless absent or empty collections, schema-owned shape requirements,
|
||||
unrelated sequences remaining unselected, and concrete-path diagnostics.
|
||||
Verify both constructors feed the same registration model, including mixed
|
||||
exact and dynamic registrations, compound identities, and definition-time
|
||||
rejection of empty identity field sets.
|
||||
Cover ancestor replacement followed by collection reintroduction, including a
|
||||
surviving append mode with and without renewed identity, explicit replacement,
|
||||
and application-registered identity. Verify that `extends: null` cannot supply
|
||||
an identity component and that structural-control exclusion uses ordinary
|
||||
value-free missing-identity diagnostics.
|
||||
Update the public contract and consumer examples when behavior lands.
|
||||
|
||||
The collection-policy and CLI-ordering decisions are accepted in
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) for collection policy and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) for CLI ordering.
|
||||
Their scoped supersession is recorded in the affected earlier ADRs, and the
|
||||
[architecture policy](../policy/architecture.md) reflects the accepted decisions.
|
||||
These govern the target architecture; the configuration contract continues to
|
||||
describe implemented behavior until the feature lands. This roadmap does not
|
||||
select migration machinery.
|
||||
|
||||
ADR 0017 now includes inheritance within identified sequences;
|
||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records that scope extension in its
|
||||
status while preserving its decision content. The architecture policy is aligned.
|
||||
Dynamic selectors extend the destinations covered by application registration;
|
||||
they retain the accepted application authority and final-validation boundary.
|
||||
|
||||
No open feature decisions remain. The implementation plan translates this end
|
||||
state into reviewable stages without adding guarantees, restrictions, or
|
||||
combination semantics.
|
||||
45
examples/consumers/discovery_test.go
Normal file
45
examples/consumers/discovery_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type workingDirectoryPresetConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
func Example_workingDirectoryPreset() {
|
||||
original, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = os.Chdir(original) }()
|
||||
root, err := os.MkdirTemp("", "configkit-working-search-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
if err := os.Chdir(root); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
result, err := configkit.Load[workingDirectoryPresetConfig](
|
||||
configkit.SearchConfig("service", configkit.WorkingDirectory()),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
|
||||
// Output: example service.yml
|
||||
}
|
||||
@@ -31,13 +31,23 @@ type SequenceCatalogEntry struct {
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
|
||||
// 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
316
facade_discovery_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
type facadeDiscoveryConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Settings map[string]string `yaml:"settings"`
|
||||
}
|
||||
|
||||
type facadeDiscoveryLifecycleConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Resolved string `yaml:"resolved"`
|
||||
}
|
||||
|
||||
type facadeDiscoveryPlacementConfig struct {
|
||||
Items []facadeDiscoveryPlacementItem `yaml:"items"`
|
||||
}
|
||||
|
||||
type facadeDiscoveryPlacementItem struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
|
||||
original, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(original); err != nil {
|
||||
t.Errorf("restore working directory: %v", err)
|
||||
}
|
||||
})
|
||||
writeFacadeImportFile(t, root, "myapp.yml", "value: working\n")
|
||||
|
||||
result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory()))
|
||||
if err != nil || result.Config.Value != "working" {
|
||||
t.Fatalf("working-directory search = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) {
|
||||
t.Fatalf("working-directory sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n")
|
||||
custom := SearchDirectory(root, DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames("config.yml")},
|
||||
})
|
||||
result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom))
|
||||
if err != nil || result.Config.Value != "found" {
|
||||
t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) {
|
||||
t.Fatalf("custom generic-only sources = %#v", sources)
|
||||
}
|
||||
|
||||
defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} }
|
||||
empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||
absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{
|
||||
Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults},
|
||||
})
|
||||
if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 {
|
||||
t.Fatalf("explicit empty filename selection = %#v, %v", absent, err)
|
||||
}
|
||||
omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root)))
|
||||
if err != nil || omitted.Config.Value != "found" {
|
||||
t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n")
|
||||
laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||
paths := []string{filepath.Join(root, "missing.yml")}
|
||||
names := []string{"config.yml"}
|
||||
groups := []FilenameGroup{CustomFilenames(names...)}
|
||||
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
||||
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
|
||||
source := SearchConfig("", search, SearchOptions{Required: true})
|
||||
|
||||
paths[0] = laterPath
|
||||
names[0] = "later.yml"
|
||||
groups[0] = CustomFilenames("later.yml")
|
||||
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
||||
search = Paths(laterPath)
|
||||
|
||||
result, err := Load[facadeDiscoveryConfig](source)
|
||||
if err != nil || result.Config.Value != "selected" {
|
||||
t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) {
|
||||
t.Fatalf("ordered search sources = %#v", sources)
|
||||
}
|
||||
|
||||
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
||||
}
|
||||
foundMissing := false
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing
|
||||
}
|
||||
if !foundMissing {
|
||||
t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
||||
result, err := LoadLayered(
|
||||
Input{Source: SearchConfig("", Paths(mainPath))},
|
||||
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
||||
})),
|
||||
}}}}},
|
||||
)
|
||||
if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) {
|
||||
t.Fatalf("layered search = %#v, %v", result, err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
|
||||
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
||||
t.Fatalf("layered search sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n")
|
||||
writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n")
|
||||
filesystem := &FilesystemContext{Root: root}
|
||||
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
||||
|
||||
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
|
||||
if err != nil || selected.Config.Value != "overlay" {
|
||||
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
||||
}
|
||||
sources := selected.Sources()
|
||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" ||
|
||||
sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay {
|
||||
t.Fatalf("selected search source evidence = %#v", sources)
|
||||
}
|
||||
mainContext, ok := selected.SourceContext("source-1")
|
||||
if !ok || mainContext.BaseDir != "." {
|
||||
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
||||
}
|
||||
|
||||
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
|
||||
searchedAbsent, err := Load(missing, options)
|
||||
if err != nil {
|
||||
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
||||
}
|
||||
explicitAbsent, err := Load(NoSource(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource with root-only overlay context: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) ||
|
||||
!reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) {
|
||||
t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent)
|
||||
}
|
||||
left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value"))
|
||||
right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value"))
|
||||
if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK {
|
||||
t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK)
|
||||
}
|
||||
if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay {
|
||||
t.Fatalf("absent search created unexpected source evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
||||
outsideRoot := t.TempDir()
|
||||
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
||||
search := SearchConfig("", Paths(outside, inside))
|
||||
|
||||
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) {
|
||||
t.Fatalf("selected outside-root file error = %v, want containment failure", err)
|
||||
}
|
||||
|
||||
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}})
|
||||
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||
t.Fatalf("selected file with explicit base error = %v, want option failure", err)
|
||||
}
|
||||
|
||||
native := NewFilesystem("native", fstest.MapFS{})
|
||||
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}})
|
||||
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||
t.Fatalf("selected file with native context error = %v, want option failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
||||
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
||||
Defaults: func() facadeDiscoveryLifecycleConfig {
|
||||
defaultsCalls++
|
||||
return facadeDiscoveryLifecycleConfig{}
|
||||
},
|
||||
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
||||
resolveCalls++
|
||||
configuration.Resolved = "from:" + configuration.Value
|
||||
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
||||
},
|
||||
})
|
||||
if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" {
|
||||
t.Fatalf("search lifecycle result = %#v, %v", result, err)
|
||||
}
|
||||
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 {
|
||||
t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" {
|
||||
t.Fatalf("selected source evidence = %#v", sources)
|
||||
}
|
||||
valueInfo, found := result.Metadata().Lookup(Keys("value"))
|
||||
if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID {
|
||||
t.Fatalf("selected value metadata = %#v, %v", valueInfo, found)
|
||||
}
|
||||
var rendered strings.Builder
|
||||
if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") {
|
||||
t.Fatalf("effective output = %q, %v", rendered.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n")
|
||||
options := Options[facadeDiscoveryPlacementConfig]{
|
||||
Destination: Keys("items"),
|
||||
PlacementMode: PlacementModeSequenceItem,
|
||||
}
|
||||
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
|
||||
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
||||
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
||||
}
|
||||
if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem {
|
||||
t.Fatalf("selected sequence-item source = %#v", sources)
|
||||
}
|
||||
|
||||
defaults := func() facadeDiscoveryPlacementConfig {
|
||||
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
||||
}
|
||||
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
||||
Destination: options.Destination,
|
||||
PlacementMode: options.PlacementMode,
|
||||
})
|
||||
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
|
||||
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
|
||||
}
|
||||
}
|
||||
|
||||
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
|
||||
for _, diagnostic := range err.Diagnostics() {
|
||||
if diagnostic.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func canonicalFacadePath(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(absolute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
69
facade_item_registration_scope_test.go
Normal file
69
facade_item_registration_scope_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, layered := range []bool{false, true} {
|
||||
for _, dynamic := range []bool{false, true} {
|
||||
for _, missingOuterID := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("layered=%t/dynamic=%t/missing-outer-id=%t", layered, dynamic, missingOuterID), func(t *testing.T) {
|
||||
payload := "prompts: [{text: helper}]\n"
|
||||
unused := configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")
|
||||
if dynamic {
|
||||
payload = "catalog: {local: {prompts: [{text: helper}]}}\n"
|
||||
unused = configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id")
|
||||
}
|
||||
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
||||
if !missingOuterID {
|
||||
item += "id: outer\n"
|
||||
}
|
||||
files := configkit.NewFilesystem("items", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||
"child-helper.yml": {Data: []byte(payload)},
|
||||
})
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
unused, configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||
}}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if layered {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: files.File("main.yml"), Imports: true}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
} else {
|
||||
result, err = configkit.Load(files.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||
}
|
||||
if missingOuterID {
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeComposeInvalid || failure.Diagnostics()[0].Path.String() != "/items/0/id" {
|
||||
t.Fatalf("outer constraint diagnostics = %#v", failure.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range []configkit.Path{configkit.Keys("items").Index(0), configkit.Keys("items").Index(0).Key("children").Index(0)} {
|
||||
path := record
|
||||
if dynamic {
|
||||
path = path.Key("catalog").Key("local")
|
||||
}
|
||||
info, found := result.Metadata().Lookup(path.Key("prompts").Index(0).Key("text"))
|
||||
if !found || !info.Explicit {
|
||||
t.Fatalf("nested helper payload lost at %s", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
internal/engine/collection_lookup_test.go
Normal file
98
internal/engine/collection_lookup_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) {
|
||||
var baseBody, overlayBody strings.Builder
|
||||
basePolicies := collectionPolicyScope{}
|
||||
var declarations []sequenceDeclarationSpec
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
fmt.Fprintf(&baseBody, "%s: {items: [{id: base}], retained: [{id: retained}], scalar: pending}\n", name)
|
||||
fmt.Fprintf(&overlayBody, "%s: {items: [{id: overlay}], added: true}\n", name)
|
||||
basePolicies.set(Keys(name, "items"), collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
for _, key := range []string{"retained", "missing", "scalar"} {
|
||||
declarations = append(declarations, sequenceDeclarationSpec{
|
||||
destination: Keys(name, key), identityFields: []string{"id"}, identityPresent: true,
|
||||
mode: sequenceModeAppend, modePresent: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
base, diagnostics := composeDocuments(parseCompositionDocument(t, "base", baseBody.String()))
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
base.collectionPolicies = basePolicies
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
overlay := parseCompositionDocument(t, "overlay", overlayBody.String())
|
||||
diagnostics = assembly.applyOverlayWithCollectionPolicy(overlay, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, declarations)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
lookup := buildDocumentLookup(assembly.current.root)
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
items, found := documentAtPathWithLookup(assembly.current.root, Keys(name, "items"), lookup)
|
||||
if !found || items.Kind != document.Sequence || len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "overlay" {
|
||||
t.Fatalf("collection %s did not append in order", name)
|
||||
}
|
||||
for _, key := range []string{"retained", "missing", "scalar"} {
|
||||
policy, found := assembly.current.collectionPolicies.at(Keys(name, key))
|
||||
if !found || !policy.identityPresent || !policy.explicitModePresent || policy.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("retained/pending declaration lost at %s/%s", name, key)
|
||||
}
|
||||
}
|
||||
if _, found := documentAtPathWithLookup(assembly.current.root, Keys(name, "missing"), lookup); found {
|
||||
t.Fatal("pending declaration created a value")
|
||||
}
|
||||
if origin := assembly.current.origins[Keys(name, "items").Index(1).Key("id").String()]; origin.Layer != LayerOverlay {
|
||||
t.Fatal("appended item lost overlay attribution")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritanceBroadCollectionsInOneDefinition(t *testing.T) {
|
||||
var body strings.Builder
|
||||
policies := collectionPolicyScope{}
|
||||
for _, definition := range []string{"base", "child"} {
|
||||
fmt.Fprintf(&body, "%s:\n", definition)
|
||||
if definition == "child" {
|
||||
body.WriteString(" extends: base\n")
|
||||
}
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
fmt.Fprintf(&body, " %s: {items: [{id: %s}]}\n", name, definition)
|
||||
policy := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
if definition == "child" {
|
||||
policy.explicitMode, policy.explicitModePresent = sequenceModeAppend, true
|
||||
}
|
||||
policies.set(Keys(definition, name, "items"), policy)
|
||||
policies.set(Keys(definition, name, "pending"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
}
|
||||
}
|
||||
root := parseCompositionDocument(t, "wide-collections", body.String())
|
||||
result, diagnostics := expandInheritanceWithPolicies(root, nil, policies, defaultProjectionLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
lookup := buildDocumentLookup(result.root)
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
items, found := documentAtPathWithLookup(result.root, Keys("child", name, "items"), lookup)
|
||||
if !found || len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "child" {
|
||||
t.Fatalf("inherited collection %s did not append in order", name)
|
||||
}
|
||||
if policy, found := result.collectionPolicies.at(Keys("child", name, "pending")); !found || !policy.identityPresent {
|
||||
t.Fatalf("pending inherited policy lost at %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
import (
|
||||
"maps"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// composedDocument is the private handoff between source assembly and later
|
||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||
@@ -89,19 +93,18 @@ type sequenceRange struct {
|
||||
projection sequenceItemProjectionIndex
|
||||
}
|
||||
|
||||
func (assigned sequenceRange) projections() []sequenceItemProjection {
|
||||
if assigned.items != nil {
|
||||
return append([]sequenceItemProjection{}, assigned.items...)
|
||||
}
|
||||
return sequenceItemProjections(assigned.end-assigned.start, assigned.start)
|
||||
}
|
||||
|
||||
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
|
||||
if assigned.projection.outputByInput != nil {
|
||||
return assigned.projection.outputForInput(inputIndex)
|
||||
}
|
||||
if assigned.items != nil {
|
||||
return newSequenceItemProjectionIndex(assigned.items).outputForInput(inputIndex)
|
||||
output, found := 0, false
|
||||
for _, item := range assigned.items {
|
||||
if item.inputIndex == inputIndex && inputIndex >= 0 && item.outputIndex >= 0 {
|
||||
output, found = item.outputIndex, true
|
||||
}
|
||||
}
|
||||
return output, found
|
||||
}
|
||||
if inputIndex < 0 || inputIndex >= assigned.end-assigned.start {
|
||||
return 0, false
|
||||
@@ -114,7 +117,13 @@ func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
||||
return assigned.projection.inputForOutput(outputIndex)
|
||||
}
|
||||
if assigned.items != nil {
|
||||
return newSequenceItemProjectionIndex(assigned.items).inputForOutput(outputIndex)
|
||||
input, found := 0, false
|
||||
for _, item := range assigned.items {
|
||||
if item.outputIndex == outputIndex && outputIndex >= 0 && item.inputIndex >= 0 {
|
||||
input, found = item.inputIndex, true
|
||||
}
|
||||
}
|
||||
return input, found
|
||||
}
|
||||
if outputIndex < assigned.start || outputIndex >= assigned.end {
|
||||
return 0, false
|
||||
@@ -123,8 +132,8 @@ func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
||||
}
|
||||
|
||||
func indexSequenceRange(assigned sequenceRange) sequenceRange {
|
||||
if assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
||||
assigned.projection = newSequenceItemProjectionIndex(assigned.projections())
|
||||
if assigned.items != nil && assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
||||
assigned.projection = newSequenceItemProjectionIndex(assigned.items)
|
||||
}
|
||||
return assigned
|
||||
}
|
||||
@@ -459,8 +468,8 @@ func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
||||
if assigned.items != nil {
|
||||
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
|
||||
}
|
||||
cloned[i].projection.outputByInput = append([]int(nil), assigned.projection.outputByInput...)
|
||||
cloned[i].projection.inputByOutput = append([]int(nil), assigned.projection.inputByOutput...)
|
||||
cloned[i].projection.outputByInput = maps.Clone(assigned.projection.outputByInput)
|
||||
cloned[i].projection.inputByOutput = maps.Clone(assigned.projection.inputByOutput)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -502,9 +511,15 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
|
||||
origins := make(map[string]Origin)
|
||||
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
|
||||
for _, assigned := range ranges {
|
||||
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], sequenceHistoryContribution{
|
||||
path: assigned.path.clone(), mode: sequenceHistoryMapped, items: assigned.projections(),
|
||||
})
|
||||
contribution := sequenceHistoryContribution{
|
||||
path: assigned.path.clone(), mode: sequenceHistoryAppend,
|
||||
offset: assigned.start, length: assigned.end - assigned.start,
|
||||
}
|
||||
if assigned.items != nil {
|
||||
contribution.mode = sequenceHistoryMapped
|
||||
contribution.items = assigned.items
|
||||
}
|
||||
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], contribution)
|
||||
}
|
||||
for sourceIndex, input := range inputs {
|
||||
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}
|
||||
|
||||
540
internal/engine/discovery.go
Normal file
540
internal/engine/discovery.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Search is an immutable ordered description of possible host-file inputs.
|
||||
// It does not access the filesystem until a later loading integration consumes it.
|
||||
type Search struct {
|
||||
entries []searchEntry
|
||||
issues []string
|
||||
}
|
||||
|
||||
// FilenameGroup is an immutable ordered group used by a directory search.
|
||||
type FilenameGroup struct {
|
||||
kind filenameGroupKind
|
||||
names []string
|
||||
issues []string
|
||||
}
|
||||
|
||||
// DirectorySearchOptions controls the filename groups used by SearchDirectory.
|
||||
// A nil Filenames slice selects the default groups; a non-nil empty slice selects none.
|
||||
type DirectorySearchOptions struct {
|
||||
Filenames []FilenameGroup
|
||||
}
|
||||
|
||||
// SearchOptions controls whether a later source consumption requires a match.
|
||||
type SearchOptions struct {
|
||||
Required bool
|
||||
}
|
||||
|
||||
// SearchConfig creates a source that selects the first matching host file when
|
||||
// consumed. Description and option errors are reported during loading.
|
||||
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||
captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)}
|
||||
source := Source{kind: sourceSearch, search: &captured, searchApp: application}
|
||||
if len(supplied) > 1 {
|
||||
source.searchIssues = []string{"SearchConfig accepts at most one options value"}
|
||||
} else if len(supplied) == 1 {
|
||||
source.searchRequired = supplied[0].Required
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
type filenameGroupKind uint8
|
||||
|
||||
const (
|
||||
filenameGroupEmpty filenameGroupKind = iota
|
||||
filenameGroupNamed
|
||||
filenameGroupGeneric
|
||||
filenameGroupCustom
|
||||
)
|
||||
|
||||
type searchEntryKind uint8
|
||||
|
||||
const (
|
||||
searchEntryPath searchEntryKind = iota + 1
|
||||
searchEntryDirectory
|
||||
)
|
||||
|
||||
type directoryBaseKind uint8
|
||||
|
||||
const (
|
||||
directoryBaseLiteral directoryBaseKind = iota
|
||||
directoryBaseUnixLocal
|
||||
directoryBaseUnixEtc
|
||||
directoryBaseUserConfig
|
||||
directoryBaseHome
|
||||
directoryBaseWorking
|
||||
)
|
||||
|
||||
type searchEntry struct {
|
||||
kind searchEntryKind
|
||||
path string
|
||||
baseKind directoryBaseKind
|
||||
directory string
|
||||
appendSlug bool
|
||||
slugPrefix string
|
||||
filenames []FilenameGroup
|
||||
}
|
||||
|
||||
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||
func NamedFilenames() FilenameGroup {
|
||||
return FilenameGroup{kind: filenameGroupNamed}
|
||||
}
|
||||
|
||||
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||
func GenericFilenames() FilenameGroup {
|
||||
return FilenameGroup{kind: filenameGroupGeneric}
|
||||
}
|
||||
|
||||
// CustomFilenames selects literal filename components in the supplied order.
|
||||
func CustomFilenames(names ...string) FilenameGroup {
|
||||
group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)}
|
||||
for _, name := range group.names {
|
||||
if !validFilenameComponent(name) {
|
||||
group.issues = append(group.issues, "custom filename must be a usable host filename component")
|
||||
}
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
// SearchDirectory searches the supplied directory using the selected filename groups.
|
||||
// With omitted options or nil Filenames, it selects Named then Generic filenames.
|
||||
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||
search := Search{}
|
||||
if len(supplied) > 1 {
|
||||
search.issues = append(search.issues, "directory search accepts at most one options value")
|
||||
}
|
||||
var groups []FilenameGroup
|
||||
if len(supplied) == 0 || supplied[0].Filenames == nil {
|
||||
groups = []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||
} else {
|
||||
groups = cloneFilenameGroups(supplied[0].Filenames)
|
||||
}
|
||||
search.entries = append(search.entries, searchEntry{
|
||||
kind: searchEntryDirectory, baseKind: directoryBaseLiteral,
|
||||
directory: directory, filenames: groups,
|
||||
})
|
||||
return search
|
||||
}
|
||||
|
||||
// Paths describes literal host-file candidates in the supplied order.
|
||||
func Paths(paths ...string) Search {
|
||||
search := Search{entries: make([]searchEntry, 0, len(paths))}
|
||||
for _, path := range append([]string(nil), paths...) {
|
||||
search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path})
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
// Searches concatenates descriptions in order and snapshots their entries.
|
||||
func Searches(searches ...Search) Search {
|
||||
result := Search{}
|
||||
for _, search := range append([]Search(nil), searches...) {
|
||||
result.entries = append(result.entries, cloneSearchEntries(search.entries)...)
|
||||
result.issues = append(result.issues, search.issues...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||
func UnixSystemDirectories() Search {
|
||||
namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||
return Search{entries: []searchEntry{
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}},
|
||||
}}
|
||||
}
|
||||
|
||||
// UserConfigDirectory describes the Go user-configuration directory searches.
|
||||
func UserConfigDirectory() Search {
|
||||
return Search{entries: []searchEntry{
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}},
|
||||
}}
|
||||
}
|
||||
|
||||
// HomeDirectory describes the Go home-directory searches.
|
||||
func HomeDirectory() Search {
|
||||
return Search{entries: []searchEntry{
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()},
|
||||
{kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}},
|
||||
}}
|
||||
}
|
||||
|
||||
// WorkingDirectory describes application-named files in the current directory.
|
||||
func WorkingDirectory() Search {
|
||||
return Search{entries: []searchEntry{{
|
||||
kind: searchEntryDirectory, baseKind: directoryBaseWorking,
|
||||
filenames: []FilenameGroup{NamedFilenames()},
|
||||
}}}
|
||||
}
|
||||
|
||||
func defaultFilenameGroups() []FilenameGroup {
|
||||
return []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||
}
|
||||
|
||||
func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup {
|
||||
if groups == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]FilenameGroup, len(groups))
|
||||
for i, group := range groups {
|
||||
cloned[i] = FilenameGroup{
|
||||
kind: group.kind, names: append([]string(nil), group.names...),
|
||||
issues: append([]string(nil), group.issues...),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneSearchEntries(entries []searchEntry) []searchEntry {
|
||||
if entries == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]searchEntry, len(entries))
|
||||
for i, entry := range entries {
|
||||
cloned[i] = entry
|
||||
cloned[i].filenames = cloneFilenameGroups(entry.filenames)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func validateSearch(search Search, application string) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
for _, issue := range search.issues {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||
}
|
||||
slugNeeded := false
|
||||
for _, entry := range search.entries {
|
||||
switch entry.kind {
|
||||
case searchEntryPath:
|
||||
if entry.path == "" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty"))
|
||||
}
|
||||
case searchEntryDirectory:
|
||||
if entry.baseKind == directoryBaseLiteral && entry.directory == "" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty"))
|
||||
}
|
||||
if entry.appendSlug {
|
||||
slugNeeded = true
|
||||
}
|
||||
for _, group := range entry.filenames {
|
||||
switch group.kind {
|
||||
case filenameGroupEmpty, filenameGroupGeneric:
|
||||
case filenameGroupNamed:
|
||||
slugNeeded = true
|
||||
case filenameGroupCustom:
|
||||
default:
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid"))
|
||||
}
|
||||
for _, issue := range group.issues {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||
}
|
||||
}
|
||||
default:
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid"))
|
||||
}
|
||||
}
|
||||
if slugNeeded && !validFilenameComponent(application) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component"))
|
||||
}
|
||||
return canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func validFilenameComponent(value string) bool {
|
||||
if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if !filepath.IsLocal(value) || strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 32 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var errUnixSystemPresetUnavailable = errors.New("Unix system directories are unavailable on this platform")
|
||||
|
||||
type discoveryDirectoryHelpers struct {
|
||||
userConfigDir func() (string, error)
|
||||
userHomeDir func() (string, error)
|
||||
workingDir func() (string, error)
|
||||
unixSupported func() bool
|
||||
}
|
||||
|
||||
type searchCandidateIterator struct {
|
||||
search Search
|
||||
application string
|
||||
helpers discoveryDirectoryHelpers
|
||||
entryIndex int
|
||||
groupIndex int
|
||||
nameIndex int
|
||||
directory string
|
||||
dirReady bool
|
||||
}
|
||||
|
||||
func newSearchCandidateIterator(search Search, application string, helpers discoveryDirectoryHelpers) (*searchCandidateIterator, []Diagnostic) {
|
||||
if diagnostics := validateSearch(search, application); len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
defaults := defaultDiscoveryDirectoryHelpers()
|
||||
if helpers.userConfigDir == nil {
|
||||
helpers.userConfigDir = defaults.userConfigDir
|
||||
}
|
||||
if helpers.userHomeDir == nil {
|
||||
helpers.userHomeDir = defaults.userHomeDir
|
||||
}
|
||||
if helpers.workingDir == nil {
|
||||
helpers.workingDir = defaults.workingDir
|
||||
}
|
||||
if helpers.unixSupported == nil {
|
||||
helpers.unixSupported = defaults.unixSupported
|
||||
}
|
||||
return &searchCandidateIterator{search: search, application: application, helpers: helpers}, nil
|
||||
}
|
||||
|
||||
func defaultDiscoveryDirectoryHelpers() discoveryDirectoryHelpers {
|
||||
return discoveryDirectoryHelpers{
|
||||
userConfigDir: os.UserConfigDir,
|
||||
userHomeDir: os.UserHomeDir,
|
||||
workingDir: os.Getwd,
|
||||
unixSupported: unixSystemPresetSupported,
|
||||
}
|
||||
}
|
||||
|
||||
// next expands one candidate at a time, resolving only the directory currently
|
||||
// reached. Directory filenames are visited before moving to the next directory.
|
||||
func (iterator *searchCandidateIterator) next() (string, bool, error) {
|
||||
for {
|
||||
if iterator.dirReady {
|
||||
entry := iterator.search.entries[iterator.entryIndex]
|
||||
for iterator.groupIndex < len(entry.filenames) {
|
||||
group := entry.filenames[iterator.groupIndex]
|
||||
count := filenameGroupLength(group)
|
||||
if iterator.nameIndex < count {
|
||||
name := filenameGroupAt(group, iterator.nameIndex, iterator.application)
|
||||
iterator.nameIndex++
|
||||
return filepath.Join(iterator.directory, name), true, nil
|
||||
}
|
||||
iterator.groupIndex++
|
||||
iterator.nameIndex = 0
|
||||
}
|
||||
iterator.dirReady = false
|
||||
iterator.entryIndex++
|
||||
}
|
||||
if iterator.entryIndex >= len(iterator.search.entries) {
|
||||
return "", false, nil
|
||||
}
|
||||
entry := iterator.search.entries[iterator.entryIndex]
|
||||
if entry.kind == searchEntryPath {
|
||||
iterator.entryIndex++
|
||||
path, err := iterator.absoluteCandidate(entry.path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
if !directoryHasCandidates(entry.filenames) {
|
||||
iterator.entryIndex++
|
||||
continue
|
||||
}
|
||||
directory, err := iterator.resolveDirectory(entry)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
iterator.directory = directory
|
||||
iterator.groupIndex = 0
|
||||
iterator.nameIndex = 0
|
||||
iterator.dirReady = true
|
||||
}
|
||||
}
|
||||
|
||||
func (iterator *searchCandidateIterator) absoluteCandidate(path string) (string, error) {
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
// Drive-relative and drive-root-relative Windows paths need native
|
||||
// resolution; joining a rooted path to the whole working directory changes it.
|
||||
if filepath.VolumeName(path) != "" || runtime.GOOS == "windows" && len(path) > 0 && os.IsPathSeparator(path[0]) {
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
working, err := iterator.helpers.workingDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(filepath.Join(working, path)), nil
|
||||
}
|
||||
|
||||
func (iterator *searchCandidateIterator) resolveDirectory(entry searchEntry) (string, error) {
|
||||
var base string
|
||||
switch entry.baseKind {
|
||||
case directoryBaseLiteral:
|
||||
base = entry.directory
|
||||
case directoryBaseUnixLocal:
|
||||
if !iterator.helpers.unixSupported() {
|
||||
return "", errUnixSystemPresetUnavailable
|
||||
}
|
||||
base = "/usr/local/etc"
|
||||
case directoryBaseUnixEtc:
|
||||
if !iterator.helpers.unixSupported() {
|
||||
return "", errUnixSystemPresetUnavailable
|
||||
}
|
||||
base = "/etc"
|
||||
case directoryBaseUserConfig:
|
||||
var err error
|
||||
base, err = iterator.helpers.userConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
case directoryBaseHome:
|
||||
var err error
|
||||
base, err = iterator.helpers.userHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
case directoryBaseWorking:
|
||||
var err error
|
||||
base, err = iterator.helpers.workingDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", errors.New("directory search base is invalid")
|
||||
}
|
||||
if entry.appendSlug {
|
||||
base = filepath.Join(base, entry.slugPrefix+iterator.application)
|
||||
}
|
||||
if entry.baseKind == directoryBaseLiteral && !filepath.IsAbs(base) {
|
||||
return iterator.absoluteCandidate(base)
|
||||
}
|
||||
return filepath.Clean(base), nil
|
||||
}
|
||||
|
||||
func directoryHasCandidates(groups []FilenameGroup) bool {
|
||||
for _, group := range groups {
|
||||
if filenameGroupLength(group) != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filenameGroupLength(group FilenameGroup) int {
|
||||
switch group.kind {
|
||||
case filenameGroupNamed:
|
||||
return 2
|
||||
case filenameGroupGeneric:
|
||||
return 4
|
||||
case filenameGroupCustom:
|
||||
return len(group.names)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func filenameGroupAt(group FilenameGroup, index int, application string) string {
|
||||
switch group.kind {
|
||||
case filenameGroupNamed:
|
||||
if index == 0 {
|
||||
return application + ".yml"
|
||||
}
|
||||
return application + ".yaml"
|
||||
case filenameGroupGeneric:
|
||||
return [...]string{"config.yml", "config.yaml", "main.yml", "main.yaml"}[index]
|
||||
case filenameGroupCustom:
|
||||
return group.names[index]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type discoveryHostProbe struct {
|
||||
evalSymlinks func(string) (string, error)
|
||||
stat func(string) (fs.FileInfo, error)
|
||||
}
|
||||
|
||||
func defaultDiscoveryHostProbe() discoveryHostProbe {
|
||||
return discoveryHostProbe{evalSymlinks: filepath.EvalSymlinks, stat: os.Stat}
|
||||
}
|
||||
|
||||
// resolveSearchInput associates a terminal candidate failure with the same file
|
||||
// evidence as ordinary acquisition. Exhaustion and base failures have no file.
|
||||
func resolveSearchInput(source Source, sourceID string, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, *SourceInfo, []Diagnostic) {
|
||||
selected, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, helpers, probe)
|
||||
if len(diagnostics) == 0 || selected.kind != sourceFile {
|
||||
return selected, nil, diagnostics
|
||||
}
|
||||
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerBase, Kind: SourceKindFile}
|
||||
for index := range diagnostics {
|
||||
diagnostics[index].Primary = &Location{SourceID: sourceID}
|
||||
}
|
||||
return selected, info, diagnostics
|
||||
}
|
||||
|
||||
// resolveSearchSource selects one ordinary file source. A nil description leaves
|
||||
// an existing source untouched; the selected file is read later by readSource.
|
||||
// A terminal candidate failure returns its file descriptor for error evidence.
|
||||
func resolveSearchSource(source Source, description *Search, application string, required bool, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, []Diagnostic) {
|
||||
if description == nil {
|
||||
return source, nil
|
||||
}
|
||||
iterator, diagnostics := newSearchCandidateIterator(*description, application, helpers)
|
||||
if len(diagnostics) != 0 {
|
||||
return Source{}, diagnostics
|
||||
}
|
||||
defaults := defaultDiscoveryHostProbe()
|
||||
if probe.evalSymlinks == nil {
|
||||
probe.evalSymlinks = defaults.evalSymlinks
|
||||
}
|
||||
if probe.stat == nil {
|
||||
probe.stat = defaults.stat
|
||||
}
|
||||
for {
|
||||
candidate, ok, err := iterator.next()
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnixSystemPresetUnavailable) {
|
||||
return Source{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Unix system directory search is unavailable on this platform")}
|
||||
}
|
||||
return Source{}, []Diagnostic{classifySourceError(err, "")}
|
||||
}
|
||||
if !ok {
|
||||
if required {
|
||||
return Source{}, []Diagnostic{sourceDiagnostic(CodeSourceMissing, "", "required configuration search found no file")}
|
||||
}
|
||||
return NoSource(), nil
|
||||
}
|
||||
canonical, err := probe.evalSymlinks(candidate)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return File(candidate), []Diagnostic{classifySourceError(err, "")}
|
||||
}
|
||||
if _, err := probe.stat(canonical); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
failed := File(candidate)
|
||||
failed.path = canonical
|
||||
return failed, []Diagnostic{classifySourceError(err, "")}
|
||||
}
|
||||
return File(candidate), nil
|
||||
}
|
||||
}
|
||||
430
internal/engine/discovery_load_test.go
Normal file
430
internal/engine/discovery_load_test.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type discoveryLoadConfig struct {
|
||||
Service struct {
|
||||
Settings map[string]string `yaml:"settings"`
|
||||
} `yaml:"service"`
|
||||
Settings map[string]string `yaml:"settings"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
func TestSearchLoadMatchesExplicitFileThroughImportsAndContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
mainPath := writeImportFile(t, root, "app.yml", `configkit:
|
||||
imports: [{path: fragments/extra.yml}]
|
||||
settings: {main: selected}
|
||||
`)
|
||||
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
|
||||
search := SearchConfig("app", SearchDirectory(root))
|
||||
options := Options[discoveryLoadConfig]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"),
|
||||
}
|
||||
searched, err := testLoad(search, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := testLoad(File(mainPath), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
||||
Keys("service", "settings", "main"), Keys("service", "settings", "imported"))
|
||||
|
||||
sources := searched.Sources()
|
||||
if len(sources) != 2 || sources[0].Kind != SourceKindFile || sources[0].Path != canonicalTestPath(t, mainPath) || sources[1].Path != canonicalTestPath(t, importPath) {
|
||||
t.Fatalf("searched source evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLayeredMainAndUnderlayMatchExplicitFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
mainPath := writeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||
underlayPath := writeImportFile(t, root, "underlay/base.yml", `configkit:
|
||||
imports: [{path: extra.yml}]
|
||||
settings: {underlay: value}
|
||||
`)
|
||||
importPath := writeImportFile(t, root, "underlay/extra.yml", "settings: {imported: value}\n")
|
||||
mainSearch := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("main.yml")}}))
|
||||
underlaySearch := SearchConfig("", SearchDirectory(filepath.Join(root, "underlay"), DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("base.yml")}}))
|
||||
load := func(main, underlay Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return LoadLayered(
|
||||
Input{Source: main, Imports: true}, testSecretBinding(),
|
||||
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: underlay, Imports: true}}}}},
|
||||
)
|
||||
}
|
||||
searched, err := load(mainSearch, underlaySearch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := load(File(mainPath), File(underlayPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
||||
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
|
||||
|
||||
sources := searched.Inspection.Sources()
|
||||
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
|
||||
t.Fatalf("layered search source evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalSearchExhaustionMatchesNoSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
missing := filepath.Join(root, "missing.yml")
|
||||
defaults := func() discoveryLoadConfig {
|
||||
return discoveryLoadConfig{Value: "default"}
|
||||
}
|
||||
options := Options[discoveryLoadConfig]{
|
||||
Definition: Definition[discoveryLoadConfig]{Defaults: defaults},
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||
}
|
||||
searched, err := testLoad(SearchConfig("", Paths(missing)), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := testLoad(NoSource(), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
||||
if len(searched.Sources()) != 0 {
|
||||
t.Fatalf("optional exhausted search created sources: %#v", searched.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSelectedFileFailuresDoNotFallBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("malformed YAML", func(t *testing.T) {
|
||||
first := writeImportFile(t, t.TempDir(), "first.yml", ": malformed\n")
|
||||
writeImportFile(t, filepath.Dir(first), "later.yml", "value: valid\n")
|
||||
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(filepath.Dir(first), "later.yml"))), Options[discoveryLoadConfig]{})
|
||||
if loaded != nil || err == nil {
|
||||
t.Fatalf("malformed selected source = %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version check", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first := writeImportFile(t, root, "first.yml", "version: rejected\n")
|
||||
writeImportFile(t, root, "later.yml", "version: accepted\n")
|
||||
var checked []string
|
||||
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[discoveryLoadConfig]{
|
||||
SourceVersionMode: sourceVersionModeIfPresent,
|
||||
Definition: Definition[discoveryLoadConfig]{Version: &VersionRule{
|
||||
Path: Keys("version"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
checked = append(checked, version.Text)
|
||||
if version.Text == "rejected" {
|
||||
return errors.New("version rejected")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
if loaded != nil || err == nil || !reflect.DeepEqual(checked, []string{"rejected"}) {
|
||||
t.Fatalf("version failure search = %#v, %v; checked %#v", loaded, err, checked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first := writeImportFile(t, root, "first.yml", "value: not-an-integer\n")
|
||||
writeImportFile(t, root, "later.yml", "value: 3\n")
|
||||
type config struct {
|
||||
Value int `yaml:"value"`
|
||||
}
|
||||
loaded, err := testLoad[config](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[config]{})
|
||||
if loaded != nil || err == nil {
|
||||
t.Fatalf("decode failure search = %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
main := writeImportFile(t, root, "main.yml", "value: main\n")
|
||||
underlay := writeImportFile(t, root, "underlay.yml", "settings: {first: underlay}\n")
|
||||
bad := filepath.Join(main, "child.yml")
|
||||
later := writeImportFile(t, root, "later.yml", "value: later\n")
|
||||
missing := filepath.Join(root, "missing.yml")
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
id string
|
||||
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||
}{
|
||||
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{destination: Keys("settings")})
|
||||
}},
|
||||
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||
Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: File(underlay)}, {Source: source, Destination: Keys("service", "settings")},
|
||||
}}},
|
||||
})
|
||||
}},
|
||||
} {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
_, err := entry.load(File(bad))
|
||||
var explicit *LoadError
|
||||
if !errors.As(err, &explicit) || len(explicit.Diagnostics()) != 1 {
|
||||
t.Fatalf("explicit failed file = %v", err)
|
||||
}
|
||||
if explicit.Diagnostics()[0].Code == CodeSourceMissing {
|
||||
t.Skip("host classifies a non-directory ancestor as absence; controlled probe tests cover access failures")
|
||||
}
|
||||
result, err := entry.load(SearchConfig("", Paths(missing, bad, later)))
|
||||
var searched *LoadError
|
||||
if result != nil || !errors.As(err, &searched) {
|
||||
t.Fatalf("failed search = %#v, %v", result, err)
|
||||
}
|
||||
if !reflect.DeepEqual(searched.Sources(), explicit.Sources()) || !reflect.DeepEqual(searched.Diagnostics(), explicit.Diagnostics()) {
|
||||
t.Fatalf("search failure differs from explicit file:\nsources %#v / %#v\ndiagnostics %#v / %#v", searched.Sources(), explicit.Sources(), searched.Diagnostics(), explicit.Diagnostics())
|
||||
}
|
||||
primary := searched.Diagnostics()[0].Primary
|
||||
if primary == nil || primary.SourceID != entry.id {
|
||||
t.Fatalf("failed candidate location = %#v, want %s", primary, entry.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchValidationPrecedesCandidateProbing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
first := writeImportFile(t, root, "first.yml", "value: selected\n")
|
||||
_, err := testLoad[discoveryLoadConfig](SearchConfig("", Searches(Paths(first), Paths(""))), Options[discoveryLoadConfig]{})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("invalid later search error = %v, want *LoadError", err)
|
||||
}
|
||||
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("invalid later search component diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
|
||||
malformed := writeImportFile(t, t.TempDir(), "main.yml", ": malformed\n")
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: SearchConfig("", Paths(malformed))}, testSecretBinding(),
|
||||
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: SearchConfig("", Paths(""))}}}}},
|
||||
)
|
||||
if loaded != nil || !errors.As(err, &loadErr) {
|
||||
t.Fatalf("invalid later underlay search = %#v, %v", loaded, err)
|
||||
}
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
if diagnostic.Code != CodeOptionsInvalid {
|
||||
t.Fatalf("layered validation acquired main before rejecting underlay: %#v", loadErr.Diagnostics())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
||||
writeImportFile(t, root, "later.yml", "value: later\n")
|
||||
search := SearchConfig("", Paths(first, filepath.Join(root, "later.yml")))
|
||||
options := Options[discoveryLoadConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}}
|
||||
_, err := testLoad[discoveryLoadConfig](search, options)
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("selected file with explicit base error = %v, want *LoadError", err)
|
||||
}
|
||||
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("selected file context diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyContextUsesSelectedOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selected := writeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||
missing := filepath.Join(root, "missing.yml")
|
||||
context := &FilesystemContext{}
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||
}{
|
||||
{"ordinary", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Imports: true, Filesystem: context})
|
||||
}},
|
||||
{"layered main", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return LoadLayered(Input{Source: source, Imports: true, Filesystem: context}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{})
|
||||
}},
|
||||
{"underlay", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: source, Imports: true, Filesystem: context}}}},
|
||||
})
|
||||
}},
|
||||
} {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
explicit, err := entry.load(File(selected))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
searched, err := entry.load(SearchConfig("", Paths(missing, selected)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
||||
if searched.Config.Value != "selected" {
|
||||
t.Fatalf("selected value = %q", searched.Config.Value)
|
||||
}
|
||||
for _, source := range []Source{NoSource(), SearchConfig("", Paths(missing))} {
|
||||
result, err := entry.load(source)
|
||||
var failure *LoadError
|
||||
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("absent input with empty context = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
file := writeImportFile(t, root, "config.yml", "value: first\n")
|
||||
source := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("config.yml")}}))
|
||||
load := func() string {
|
||||
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loaded.Config.Value
|
||||
}
|
||||
if value := load(); value != "first" {
|
||||
t.Fatalf("first search value = %q", value)
|
||||
}
|
||||
if err := os.WriteFile(file, []byte("value: second\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value := load(); value != "second" {
|
||||
t.Fatalf("updated search value = %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSourceObservesUserConfigEnvironmentChanges(t *testing.T) {
|
||||
setBase := func(directory string) string {
|
||||
t.Helper()
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("AppData", directory)
|
||||
case "darwin", "ios":
|
||||
t.Setenv("HOME", directory)
|
||||
case "plan9":
|
||||
t.Setenv("home", directory)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", directory)
|
||||
}
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base
|
||||
}
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
firstBase := setBase(filepath.Join(t.TempDir(), "first"))
|
||||
first := writeImportFile(t, firstBase, "app/app.yml", "value: first\n")
|
||||
source := SearchConfig("app", UserConfigDirectory())
|
||||
load := func() string {
|
||||
loaded, err := testLoad[config](source, Options[config]{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loaded.Config.Value
|
||||
}
|
||||
if value := load(); value != "first" {
|
||||
t.Fatalf("first user-config search value = %q (%s)", value, first)
|
||||
}
|
||||
secondBase := setBase(filepath.Join(t.TempDir(), "second"))
|
||||
second := writeImportFile(t, secondBase, "app/app.yml", "value: second\n")
|
||||
if value := load(); value != "second" {
|
||||
t.Fatalf("updated user-config search value = %q (%s)", value, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSourceObservesWorkingDirectoryChanges(t *testing.T) {
|
||||
original, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(original); err != nil {
|
||||
t.Errorf("restore working directory: %v", err)
|
||||
}
|
||||
})
|
||||
firstDir := t.TempDir()
|
||||
secondDir := t.TempDir()
|
||||
writeImportFile(t, firstDir, "app.yml", "value: first\n")
|
||||
writeImportFile(t, secondDir, "app.yml", "value: second\n")
|
||||
source := SearchConfig("app", WorkingDirectory())
|
||||
load := func() string {
|
||||
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loaded.Config.Value
|
||||
}
|
||||
if err := os.Chdir(firstDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value := load(); value != "first" {
|
||||
t.Fatalf("first working-directory search value = %q", value)
|
||||
}
|
||||
if err := os.Chdir(secondDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value := load(); value != "second" {
|
||||
t.Fatalf("updated working-directory search value = %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEquivalentDiscoveryLoads[T any](t *testing.T, leftConfig T, left *Inspection, rightConfig T, right *Inspection, presencePaths ...Path) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(leftConfig, rightConfig) {
|
||||
t.Fatalf("search config %#v differs from explicit file config %#v", leftConfig, rightConfig)
|
||||
}
|
||||
leftSources, rightSources := left.Sources(), right.Sources()
|
||||
if !reflect.DeepEqual(leftSources, rightSources) {
|
||||
t.Fatalf("search sources %#v differ from explicit file sources %#v", leftSources, rightSources)
|
||||
}
|
||||
leftMetadata, rightMetadata := left.Metadata(), right.Metadata()
|
||||
for _, path := range presencePaths {
|
||||
leftInfo, leftPresent := leftMetadata.Lookup(path)
|
||||
rightInfo, rightPresent := rightMetadata.Lookup(path)
|
||||
if leftPresent != rightPresent || !reflect.DeepEqual(leftInfo, rightInfo) {
|
||||
t.Fatalf("search metadata at %s = %#v, %v; explicit metadata = %#v, %v", path.String(), leftInfo, leftPresent, rightInfo, rightPresent)
|
||||
}
|
||||
}
|
||||
for _, source := range leftSources {
|
||||
leftContext, leftOK := left.SourceContext(source.ID)
|
||||
rightContext, rightOK := right.SourceContext(source.ID)
|
||||
if leftOK != rightOK || leftContext.BaseDir != rightContext.BaseDir || (leftContext.FS != nil) != (rightContext.FS != nil) {
|
||||
t.Fatalf("search context for %s = %#v, %v; explicit context = %#v, %v", source.ID, leftContext, leftOK, rightContext, rightOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
internal/engine/discovery_platform_other.go
Normal file
5
internal/engine/discovery_platform_other.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !hurd && !illumos && !ios && !linux && !netbsd && !openbsd && !solaris
|
||||
|
||||
package engine
|
||||
|
||||
func unixSystemPresetSupported() bool { return false }
|
||||
5
internal/engine/discovery_platform_unix.go
Normal file
5
internal/engine/discovery_platform_unix.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build aix || android || darwin || dragonfly || freebsd || hurd || illumos || ios || linux || netbsd || openbsd || solaris
|
||||
|
||||
package engine
|
||||
|
||||
func unixSystemPresetSupported() bool { return true }
|
||||
519
internal/engine/discovery_test.go
Normal file
519
internal/engine/discovery_test.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("zero search diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("empty concatenation diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("empty path list diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics)
|
||||
}
|
||||
generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}})
|
||||
if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 {
|
||||
t.Fatalf("valid preset slug diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionSnapshotsInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paths := []string{"first.yml", "second.yml"}
|
||||
pathSearch := Paths(paths...)
|
||||
paths[0] = "changed.yml"
|
||||
|
||||
customNames := []string{"one.yml", "two.yml"}
|
||||
group := CustomFilenames(customNames...)
|
||||
customNames[0] = "changed.yml"
|
||||
groups := []FilenameGroup{group}
|
||||
options := DirectorySearchOptions{Filenames: groups}
|
||||
directorySearch := SearchDirectory("configs", options)
|
||||
groups[0] = GenericFilenames()
|
||||
options.Filenames[0] = NamedFilenames()
|
||||
|
||||
searches := []Search{pathSearch, directorySearch}
|
||||
combined := Searches(searches...)
|
||||
searches[0] = Search{}
|
||||
|
||||
if got := combined.entries[0].path; got != "first.yml" {
|
||||
t.Fatalf("snapshotted path = %q", got)
|
||||
}
|
||||
gotNames := combined.entries[2].filenames[0].names
|
||||
if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" {
|
||||
t.Fatalf("snapshotted custom names = %#v", gotNames)
|
||||
}
|
||||
if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("snapshot description diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
omitted := SearchDirectory("configs")
|
||||
if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric {
|
||||
t.Fatalf("omitted groups = %#v", omitted.entries)
|
||||
}
|
||||
explicitNil := SearchDirectory("configs", DirectorySearchOptions{})
|
||||
if len(explicitNil.entries[0].filenames) != 2 {
|
||||
t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames)
|
||||
}
|
||||
explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||
if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 {
|
||||
t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames)
|
||||
}
|
||||
if len(validateSearch(explicitEmpty, "")) != 0 {
|
||||
t.Fatal("explicit empty filename selection should need no slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{})
|
||||
if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 {
|
||||
t.Fatal("multiple option values should be invalid")
|
||||
}
|
||||
invalid := Paths("")
|
||||
combined := Searches(Paths("good.yml"), invalid)
|
||||
if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 {
|
||||
t.Fatal("concatenation lost a constructor error")
|
||||
}
|
||||
if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 {
|
||||
t.Fatal("later invalid path was hidden by an earlier component")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")}
|
||||
search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups})
|
||||
got := search.entries[0].filenames
|
||||
if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" {
|
||||
t.Fatalf("filename group order = %#v", got)
|
||||
}
|
||||
if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 {
|
||||
t.Fatalf("empty custom group = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameComponentValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} {
|
||||
if validFilenameComponent(value) {
|
||||
t.Errorf("validFilenameComponent(%q) = true", value)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, value := range []string{"CON", "CONIN$", "CONOUT$", "name.", "bad:name", "bad\x01name"} {
|
||||
if validFilenameComponent(value) {
|
||||
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||
}
|
||||
}
|
||||
// Device names with extensions depend on the Windows version. Match
|
||||
// Go's platform-aware decision instead of imposing a blanket ban.
|
||||
for _, value := range []string{"nul.txt", "COM¹.log", "LPT³.txt", "CON.extra.yml"} {
|
||||
if got, want := validFilenameComponent(value), filepath.IsLocal(value); got != want {
|
||||
t.Errorf("Windows component %q validity = %v, want Go's %v", value, got, want)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, value := range []string{"CONIN$", "CONOUT$", "nul.txt"} {
|
||||
if !validFilenameComponent(value) {
|
||||
t.Errorf("Windows component restriction applied to this host: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"name with spaces", "naïve"} {
|
||||
if !validFilenameComponent(value) {
|
||||
t.Errorf("host-valid component %q rejected", value)
|
||||
}
|
||||
}
|
||||
if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 {
|
||||
t.Fatal("invalid custom filename was not retained for validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCandidatesPreserveDirectoryAndGroupOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||
search := Searches(
|
||||
Paths("first.yml"),
|
||||
SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{
|
||||
GenericFilenames(), CustomFilenames("special.yml"),
|
||||
}}),
|
||||
Paths("last.yml"),
|
||||
)
|
||||
candidates := collectSearchCandidates(t, search, "app", helpers)
|
||||
want := []string{
|
||||
filepath.Join(root, "first.yml"),
|
||||
filepath.Join(root, "settings", "config.yml"),
|
||||
filepath.Join(root, "settings", "config.yaml"),
|
||||
filepath.Join(root, "settings", "main.yml"),
|
||||
filepath.Join(root, "settings", "main.yaml"),
|
||||
filepath.Join(root, "settings", "special.yml"),
|
||||
filepath.Join(root, "last.yml"),
|
||||
}
|
||||
assertCandidatePaths(t, candidates, want)
|
||||
|
||||
reversed := SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{NamedFilenames(), GenericFilenames()}})
|
||||
reversedCandidates := collectSearchCandidates(t, reversed, "app", helpers)
|
||||
assertCandidatePaths(t, reversedCandidates[:2], []string{
|
||||
filepath.Join(root, "settings", "app.yml"),
|
||||
filepath.Join(root, "settings", "app.yaml"),
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchPresetCandidateLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
helpers := discoveryDirectoryHelpers{
|
||||
userConfigDir: func() (string, error) { return "/controlled/config", nil },
|
||||
userHomeDir: func() (string, error) { return "/controlled/home", nil },
|
||||
workingDir: func() (string, error) { return "/controlled/work", nil },
|
||||
unixSupported: func() bool { return true },
|
||||
}
|
||||
|
||||
unix := collectSearchCandidates(t, UnixSystemDirectories(), "app", helpers)
|
||||
if len(unix) != 14 {
|
||||
t.Fatalf("Unix preset candidate count = %d: %#v", len(unix), unix)
|
||||
}
|
||||
assertCandidatePaths(t, unix, []string{
|
||||
"/usr/local/etc/app/app.yml", "/usr/local/etc/app/app.yaml", "/usr/local/etc/app/config.yml", "/usr/local/etc/app/config.yaml", "/usr/local/etc/app/main.yml", "/usr/local/etc/app/main.yaml",
|
||||
"/etc/app/app.yml", "/etc/app/app.yaml", "/etc/app/config.yml", "/etc/app/config.yaml", "/etc/app/main.yml", "/etc/app/main.yaml",
|
||||
"/etc/app.yml", "/etc/app.yaml",
|
||||
})
|
||||
|
||||
user := collectSearchCandidates(t, UserConfigDirectory(), "app", helpers)
|
||||
assertCandidatePaths(t, user, []string{
|
||||
"/controlled/config/app/app.yml", "/controlled/config/app/app.yaml", "/controlled/config/app/config.yml", "/controlled/config/app/config.yaml", "/controlled/config/app/main.yml", "/controlled/config/app/main.yaml",
|
||||
"/controlled/config/app.yml", "/controlled/config/app.yaml",
|
||||
})
|
||||
home := collectSearchCandidates(t, HomeDirectory(), "app", helpers)
|
||||
assertCandidatePaths(t, home, []string{
|
||||
"/controlled/home/.app/app.yml", "/controlled/home/.app/app.yaml", "/controlled/home/.app/config.yml", "/controlled/home/.app/config.yaml", "/controlled/home/.app/main.yml", "/controlled/home/.app/main.yaml",
|
||||
"/controlled/home/app.yml", "/controlled/home/app.yaml",
|
||||
})
|
||||
working := collectSearchCandidates(t, WorkingDirectory(), "app", helpers)
|
||||
assertCandidatePaths(t, working, []string{"/controlled/work/app.yml", "/controlled/work/app.yaml"})
|
||||
}
|
||||
|
||||
func TestSearchCandidateExpansionIsLazyAndSkipsEmptyDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configCalls := 0
|
||||
helpers := discoveryDirectoryHelpers{
|
||||
userConfigDir: func() (string, error) {
|
||||
configCalls++
|
||||
return "/controlled/config", nil
|
||||
},
|
||||
workingDir: func() (string, error) { return "/controlled/work", nil },
|
||||
unixSupported: func() bool { return true },
|
||||
}
|
||||
search := Searches(Paths("/selected.yml"), UserConfigDirectory())
|
||||
iterator, diagnostics := newSearchCandidateIterator(search, "app", helpers)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("iterator diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/selected.yml" {
|
||||
t.Fatalf("first candidate = %q, %v, %v", candidate, ok, err)
|
||||
}
|
||||
if configCalls != 0 {
|
||||
t.Fatalf("later user directory resolved before consumption: %d calls", configCalls)
|
||||
}
|
||||
|
||||
emptyOnly := SearchDirectory("should-not-resolve", DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||
emptyCandidates := collectSearchCandidates(t, emptyOnly, "", helpers)
|
||||
if len(emptyCandidates) != 0 || configCalls != 0 {
|
||||
t.Fatalf("empty directory candidates = %#v, helper calls = %d", emptyCandidates, configCalls)
|
||||
}
|
||||
continued := collectSearchCandidates(t, Searches(emptyOnly, Paths("later.yml")), "", helpers)
|
||||
assertCandidatePaths(t, continued, []string{filepath.Join("/controlled/work", "later.yml")})
|
||||
if configCalls != 0 {
|
||||
t.Fatalf("empty directory base was resolved: %d calls", configCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCandidateBaseFailuresAndPlatformApplicability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseFailure := errors.New("controlled base failure")
|
||||
helpers := discoveryDirectoryHelpers{
|
||||
userConfigDir: func() (string, error) { return "", baseFailure },
|
||||
workingDir: func() (string, error) { return "/controlled/work", nil },
|
||||
unixSupported: func() bool { return false },
|
||||
}
|
||||
iterator, diagnostics := newSearchCandidateIterator(UserConfigDirectory(), "app", helpers)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("user preset validation = %#v", diagnostics)
|
||||
}
|
||||
if _, ok, err := iterator.next(); ok || !errors.Is(err, baseFailure) {
|
||||
t.Fatalf("unavailable user base = ok %v, err %v", ok, err)
|
||||
}
|
||||
|
||||
iterator, diagnostics = newSearchCandidateIterator(UnixSystemDirectories(), "app", helpers)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("Unix preset validation = %#v", diagnostics)
|
||||
}
|
||||
if _, ok, err := iterator.next(); ok || !errors.Is(err, errUnixSystemPresetUnavailable) {
|
||||
t.Fatalf("unsupported Unix preset = ok %v, err %v", ok, err)
|
||||
}
|
||||
|
||||
iterator, diagnostics = newSearchCandidateIterator(Searches(Paths("/earlier.yml"), UnixSystemDirectories()), "app", helpers)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composed Unix preset validation = %#v", diagnostics)
|
||||
}
|
||||
if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/earlier.yml" {
|
||||
t.Fatalf("earlier candidate = %q, %v, %v", candidate, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeAndShellLikeSearchPathsRemainLiteral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||
search := Searches(Paths("~/literal.yml", "$CONFIG/*.yaml"), SearchDirectory("./settings", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("name with spaces.yml")}}))
|
||||
candidates := collectSearchCandidates(t, search, "", helpers)
|
||||
assertCandidatePaths(t, candidates, []string{
|
||||
filepath.Join(root, "~/literal.yml"),
|
||||
filepath.Join(root, "$CONFIG/*.yaml"),
|
||||
filepath.Join(root, "settings", "name with spaces.yml"),
|
||||
})
|
||||
}
|
||||
|
||||
func collectSearchCandidates(t *testing.T, search Search, application string, helpers discoveryDirectoryHelpers) []string {
|
||||
t.Helper()
|
||||
iterator, diagnostics := newSearchCandidateIterator(search, application, helpers)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("search validation = %#v", diagnostics)
|
||||
}
|
||||
var candidates []string
|
||||
for {
|
||||
candidate, ok, err := iterator.next()
|
||||
if err != nil {
|
||||
t.Fatalf("expand candidate: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
return candidates
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCandidatePaths(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("candidate count = %d, want %d: %#v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("candidate %d = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchSelectsFirstExistingCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "first.yml")
|
||||
second := filepath.Join(root, "second.yml")
|
||||
writeDiscoveryFile(t, first, "value: first\n")
|
||||
writeDiscoveryFile(t, second, "value: second\n")
|
||||
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(filepath.Join(root, "missing.yml"), first, second)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || selected.kind != sourceFile || selected.path != first {
|
||||
t.Fatalf("selection = %#v, diagnostics %#v", selected, diagnostics)
|
||||
}
|
||||
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
|
||||
if len(diagnostics) != 0 || string(read.data) != "value: first\n" || read.info.Path != canonicalTestPath(t, first) {
|
||||
t.Fatalf("selected source read = %#v, diagnostics %#v", read, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchNamedExtensionsAndNonregularSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
writeDiscoveryFile(t, filepath.Join(root, "app.yml"), "value: yml\n")
|
||||
writeDiscoveryFile(t, filepath.Join(root, "app.yaml"), "value: yaml\n")
|
||||
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(SearchDirectory(root)), "app", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || selected.path != filepath.Join(root, "app.yml") {
|
||||
t.Fatalf("named extension selection = %#v, %#v", selected, diagnostics)
|
||||
}
|
||||
|
||||
directory := filepath.Join(root, "subdirectory")
|
||||
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good := filepath.Join(root, "good.yml")
|
||||
writeDiscoveryFile(t, good, "value: later\n")
|
||||
selected, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(directory, good)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || selected.path != directory {
|
||||
t.Fatalf("directory candidate selection = %#v, %#v", selected, diagnostics)
|
||||
}
|
||||
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
|
||||
t.Fatalf("selected directory read diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchContainedSymlinkAndNoFallbackAfterReadFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "actual.yml")
|
||||
link := filepath.Join(root, "linked.yml")
|
||||
writeDiscoveryFile(t, target, "value: linked\n")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
writeDiscoveryFile(t, filepath.Join(root, "later.yml"), "value: later\n")
|
||||
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(link, filepath.Join(root, "later.yml"))), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || selected.path != link {
|
||||
t.Fatalf("symlink selection = %#v, %#v", selected, diagnostics)
|
||||
}
|
||||
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
|
||||
if len(diagnostics) != 0 || string(read.data) != "value: linked\n" || read.info.Path != canonicalTestPath(t, target) {
|
||||
t.Fatalf("symlink read = %#v, %#v", read, diagnostics)
|
||||
}
|
||||
if err := os.Remove(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
|
||||
t.Fatalf("selected source read failure = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchRequiredAndOptionalExhaustion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
optional, diagnostics := resolveSearchSource(Source{}, ptrSearch(Search{}), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || optional.kind != sourceNone {
|
||||
t.Fatalf("optional empty search = %#v, %#v", optional, diagnostics)
|
||||
}
|
||||
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Search{}), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing || diagnostics[0].Primary != nil || !strings.Contains(diagnostics[0].Message, "search") {
|
||||
t.Fatalf("required empty search = %#v", diagnostics)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
missing := filepath.Join(root, "missing.yml")
|
||||
optional, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || optional.kind != sourceNone {
|
||||
t.Fatalf("optional missing search = %#v, %#v", optional, diagnostics)
|
||||
}
|
||||
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
|
||||
t.Fatalf("required missing search = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchLeavesNonSearchSourcesUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := Bytes("memory", []byte("value: unchanged\n"))
|
||||
resolved, diagnostics := resolveSearchSource(source, nil, "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || resolved.kind != sourceBytes || string(resolved.data) != "value: unchanged\n" {
|
||||
t.Fatalf("non-search source = %#v, diagnostics = %#v", resolved, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchStopsOnReachedProbeError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "first.yml")
|
||||
second := filepath.Join(root, "second.yml")
|
||||
writeDiscoveryFile(t, first, "value: first\n")
|
||||
writeDiscoveryFile(t, second, "value: second\n")
|
||||
statCalls := 0
|
||||
probe := discoveryHostProbe{stat: func(path string) (fs.FileInfo, error) {
|
||||
statCalls++
|
||||
return nil, fs.ErrPermission
|
||||
}}
|
||||
selected, info, diagnostics := resolveSearchInput(SearchConfig("", Paths(first, second)), "source-3", discoveryDirectoryHelpers{}, probe)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourcePermission || statCalls != 1 {
|
||||
t.Fatalf("probe failure = %#v, stat calls = %d", diagnostics, statCalls)
|
||||
}
|
||||
if selected.kind != sourceFile || info == nil || info.Path != canonicalTestPath(t, first) || info.Label != first || info.Kind != SourceKindFile || info.ID != "source-3" {
|
||||
t.Fatalf("failed candidate evidence = %#v, %#v", selected, info)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != info.ID {
|
||||
t.Fatalf("failed candidate diagnostic does not identify its file: %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchLeavesLaterDirectoryUnresolvedAfterSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "selected.yml")
|
||||
writeDiscoveryFile(t, first, "value: selected\n")
|
||||
baseCalls := 0
|
||||
helpers := discoveryDirectoryHelpers{userConfigDir: func() (string, error) {
|
||||
baseCalls++
|
||||
return "", errors.New("later base should not be reached")
|
||||
}}
|
||||
search := ptrSearch(Searches(Paths(first), UserConfigDirectory()))
|
||||
selected, diagnostics := resolveSearchSource(Source{}, search, "app", false, helpers, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || selected.path != first || baseCalls != 0 {
|
||||
t.Fatalf("selected = %#v, diagnostics = %#v, later base calls = %d", selected, diagnostics, baseCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSearchTreatsShellLikeNamesLiterally(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
paths := []string{"~/literal.yml", "$CONFIG/literal.yml", "*.yaml"}
|
||||
for _, name := range paths {
|
||||
writeDiscoveryFile(t, filepath.Join(root, name), "value: literal\n")
|
||||
}
|
||||
helper := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||
for _, name := range paths {
|
||||
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(name)), "", false, helper, discoveryHostProbe{})
|
||||
if len(diagnostics) != 0 || selected.path != filepath.Join(root, name) {
|
||||
t.Fatalf("literal %q selection = %#v, %#v", name, selected, diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptrSearch(search Search) *Search { return &search }
|
||||
|
||||
func writeDiscoveryFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
91
internal/engine/discovery_windows_test.go
Normal file
91
internal/engine/discovery_windows_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
//go:build windows
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWindowsSearchRejectsReservedComponentsBeforeAcquisition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
||||
for _, name := range []string{"CONIN$", "CONOUT$"} {
|
||||
for _, source := range []Source{
|
||||
SearchConfig(name, Searches(Paths(first), WorkingDirectory())),
|
||||
SearchConfig("", Searches(Paths(first), SearchDirectory(root, DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames(name)},
|
||||
}))),
|
||||
} {
|
||||
result, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||
var failure *LoadError
|
||||
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("reserved component %q accepted: %#v, %v", name, result, err)
|
||||
}
|
||||
if len(failure.Sources()) != 0 {
|
||||
t.Fatalf("reserved component %q was checked after acquisition: %#v", name, failure.Sources())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsSearchPathsMatchHostAbsoluteResolution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
file := writeImportFile(t, root, "settings/config.yml", "value: selected\n")
|
||||
working := t.TempDir()
|
||||
t.Chdir(working)
|
||||
relative, err := filepath.Rel(working, file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
volume := filepath.VolumeName(file)
|
||||
rooted := strings.TrimPrefix(file, volume)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"relative", relative},
|
||||
{"drive root relative", rooted},
|
||||
{"slash root relative", filepath.ToSlash(rooted)},
|
||||
{"drive relative", volume + relative},
|
||||
{"absolute", file},
|
||||
} {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
want, err := filepath.Abs(entry.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := testLoad[discoveryLoadConfig](File(entry.path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, search := range []Search{
|
||||
Paths(entry.path),
|
||||
SearchDirectory(filepath.Dir(entry.path), DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames(filepath.Base(entry.path))},
|
||||
}),
|
||||
} {
|
||||
iterator, diagnostics := newSearchCandidateIterator(search, "", discoveryDirectoryHelpers{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
candidate, ok, err := iterator.next()
|
||||
if err != nil || !ok || candidate != want {
|
||||
t.Fatalf("candidate = %q, %v, %v; want %q", candidate, ok, err, want)
|
||||
}
|
||||
result, err := testLoad[discoveryLoadConfig](SearchConfig("", search))
|
||||
if err != nil || result.Config.Value != explicit.Config.Value {
|
||||
t.Fatalf("searched path = %#v, %v; explicit value %q", result, err, explicit.Config.Value)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Path != explicit.Sources()[0].Path {
|
||||
t.Fatalf("searched source = %#v; explicit source %#v", sources, explicit.Sources())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||
if source.kind == sourceNone && context.FS == nil {
|
||||
return validateAbsentHostFilesystemContext(context)
|
||||
}
|
||||
if nativeContext(source, context) {
|
||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||
if diagnostic != nil {
|
||||
@@ -33,10 +36,57 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic {
|
||||
rootPath, err := canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return &value
|
||||
}
|
||||
basePath := rootPath
|
||||
if context.BaseDir != "" {
|
||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return &value
|
||||
}
|
||||
}
|
||||
if !containedPath(rootPath, basePath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "import base must stay inside the configuration root", nil)
|
||||
return &value
|
||||
}
|
||||
for _, candidate := range []string{rootPath, basePath} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||
return &value
|
||||
}
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
value.Primary = nil
|
||||
return &value
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func 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,6 +119,17 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
|
||||
return nil
|
||||
}
|
||||
|
||||
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 nativeContext(source Source, context *FilesystemContext) bool {
|
||||
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
||||
}
|
||||
|
||||
@@ -268,6 +268,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 := ""
|
||||
|
||||
@@ -319,7 +319,7 @@ func composeAcquiredInputWithPolicies(input assembledInput, lower collectionPoli
|
||||
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, policy); len(diagnostics) != 0 {
|
||||
if diagnostics := traversal.composePendingItems(&localScope); len(diagnostics) != 0 {
|
||||
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
|
||||
}
|
||||
documents := make([]*document.Node, 0, len(localScope.contributions))
|
||||
@@ -405,7 +405,7 @@ func composeAcquiredScopeWithPolicy(
|
||||
accounting: accounting, limits: defaultImportLimits(), rule: rule,
|
||||
versionState: versionState, importGraph: graph.clone(),
|
||||
}
|
||||
if diagnostics := traversal.composePendingItems(scope, policy); len(diagnostics) != 0 {
|
||||
if diagnostics := traversal.composePendingItems(scope); len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
documents := make([]*document.Node, 0, len(scope.contributions))
|
||||
@@ -765,22 +765,25 @@ func (s *importTraversal) scheduleAcquisitionScopes(scope *acquisitionScope) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *importTraversal) composePendingItems(scope *acquisitionScope, policy sequencePolicy) []Diagnostic {
|
||||
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, policy); len(diagnostics) != 0 {
|
||||
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(
|
||||
policy, itemScope.collectionPolicies,
|
||||
sequencePolicy{}, itemScope.collectionPolicies,
|
||||
acquiredSequenceDeclarations(*itemScope), documents...,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
|
||||
@@ -398,7 +398,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,
|
||||
}}}}},
|
||||
|
||||
@@ -114,26 +114,7 @@ func cloneInheritanceOperations(operations []inheritanceOperation) []inheritance
|
||||
}
|
||||
|
||||
func inheritancePolicyScopeAtPath(scope collectionPolicyScope, prefix Path) collectionPolicyScope {
|
||||
localized := collectionPolicyScope{}
|
||||
for _, entry := range scope.collections {
|
||||
if relative, inside := relativeHistoryPath(prefix, entry.destination); inside {
|
||||
localized.set(relative, cloneCollectionPolicyForInheritance(entry.policy))
|
||||
continue
|
||||
}
|
||||
relative, inside := relativeHistoryPath(entry.destination, prefix)
|
||||
if !inside || len(relative.segments) == 0 || relative.segments[0].kind != pathSegmentIndex {
|
||||
continue
|
||||
}
|
||||
record, found := entry.policy.recordPolicyAt(relative.segments[0].index)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
remainder := Path{segments: append([]pathSegment(nil), relative.segments[1:]...)}
|
||||
for _, nested := range inheritancePolicyScopeAtPath(record.scope, remainder).collections {
|
||||
localized.set(nested.destination, cloneCollectionPolicyForInheritance(nested.policy))
|
||||
}
|
||||
}
|
||||
return localized
|
||||
return newInheritancePolicyIndex(scope).localize(prefix)
|
||||
}
|
||||
|
||||
func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix Path) []projectedCollectionPolicy {
|
||||
|
||||
@@ -103,15 +103,19 @@ func mergeInheritanceCollectionPolicies(
|
||||
for key, entry := range child.collections {
|
||||
paths[key] = entry.destination.clone()
|
||||
}
|
||||
var lookup *documentLookup
|
||||
if len(paths) != 0 {
|
||||
lookup = buildDocumentLookup(parentValue, childValue)
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
for _, path := range paths {
|
||||
relative, inside := relativeHistoryPath(parentRootPath, path)
|
||||
var retained, incoming *document.Node
|
||||
if inside {
|
||||
if !replaced.contains(path, false) {
|
||||
retained, _ = documentAtPath(parentValue, relative)
|
||||
retained, _ = documentAtPathWithLookup(parentValue, relative, lookup)
|
||||
}
|
||||
incoming, _ = documentAtPath(childValue, relative)
|
||||
incoming, _ = documentAtPathWithLookup(childValue, relative, lookup)
|
||||
}
|
||||
resolution, lower, _, active, issues := inheritanceCollectionResolution(path, parent, child, applicationPolicy, incoming)
|
||||
if len(issues) != 0 {
|
||||
|
||||
123
internal/engine/inheritance_policy_index.go
Normal file
123
internal/engine/inheritance_policy_index.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package engine
|
||||
|
||||
// inheritancePolicyIndex borrows immutable policy entries. Semantic path
|
||||
// segments keep mapping keys distinct from sequence indexes. Record-owned
|
||||
// scopes have their own indexes, just as they have their own policy tables.
|
||||
type inheritancePolicyIndex struct {
|
||||
entry *collectionPolicyEntry
|
||||
children map[pathSegment]*inheritancePolicyIndex
|
||||
records map[int]*inheritancePolicyIndex
|
||||
}
|
||||
|
||||
func newInheritancePolicyIndex(scope collectionPolicyScope) *inheritancePolicyIndex {
|
||||
index := &inheritancePolicyIndex{}
|
||||
for _, entry := range scope.collections {
|
||||
index.set(entry)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (index *inheritancePolicyIndex) set(entry collectionPolicyEntry) {
|
||||
node := index
|
||||
for _, segment := range entry.destination.segments {
|
||||
if node.children == nil {
|
||||
node.children = make(map[pathSegment]*inheritancePolicyIndex)
|
||||
}
|
||||
if node.children[segment] == nil {
|
||||
node.children[segment] = &inheritancePolicyIndex{}
|
||||
}
|
||||
node = node.children[segment]
|
||||
}
|
||||
node.entry = &entry
|
||||
node.records = nil
|
||||
for position, owner := range entry.policy.recordOrder {
|
||||
scope := entry.policy.recordPolicies[owner]
|
||||
if len(scope.collections) == 0 {
|
||||
continue
|
||||
}
|
||||
if node.records == nil {
|
||||
node.records = make(map[int]*inheritancePolicyIndex)
|
||||
}
|
||||
node.records[position] = newInheritancePolicyIndex(scope)
|
||||
}
|
||||
}
|
||||
|
||||
// localize copies only policies in the requested scope. Earlier record-owned
|
||||
// policies are considered before direct entries added by destination expansion.
|
||||
func (index *inheritancePolicyIndex) localize(prefix Path) collectionPolicyScope {
|
||||
localized := collectionPolicyScope{}
|
||||
node := index
|
||||
for offset, segment := range prefix.segments {
|
||||
if node == nil {
|
||||
return localized
|
||||
}
|
||||
if segment.kind == pathSegmentIndex {
|
||||
if record := node.records[segment.index]; record != nil {
|
||||
remainder := Path{segments: prefix.segments[offset+1:]}
|
||||
for _, entry := range record.localize(remainder).collections {
|
||||
localized.set(entry.destination, entry.policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
node = node.children[segment]
|
||||
}
|
||||
var collect func(*inheritancePolicyIndex)
|
||||
collect = func(node *inheritancePolicyIndex) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.entry != nil {
|
||||
relative, _ := relativeHistoryPath(prefix, node.entry.destination)
|
||||
localized.set(relative, cloneCollectionPolicyForInheritance(node.entry.policy))
|
||||
}
|
||||
for _, child := range node.children {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
collect(node)
|
||||
return localized
|
||||
}
|
||||
|
||||
func (index *inheritancePolicyIndex) absoluteScope(prefix Path) collectionPolicyScope {
|
||||
scope := collectionPolicyScope{}
|
||||
for _, entry := range index.localize(prefix).collections {
|
||||
scope.set(appendDestination(prefix, entry.destination), entry.policy)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
// replace updates the walker's owned table and its borrowed index together.
|
||||
// Retirement visits only direct entries below the changed destination.
|
||||
func (index *inheritancePolicyIndex) replace(scope *collectionPolicyScope, prefix Path, replacement collectionPolicyScope) {
|
||||
node := index
|
||||
var parent *inheritancePolicyIndex
|
||||
var last pathSegment
|
||||
for _, segment := range prefix.segments {
|
||||
if node == nil {
|
||||
break
|
||||
}
|
||||
parent, last, node = node, segment, node.children[segment]
|
||||
}
|
||||
var remove func(*inheritancePolicyIndex)
|
||||
remove = func(node *inheritancePolicyIndex) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.entry != nil {
|
||||
delete(scope.collections, node.entry.destination.String())
|
||||
}
|
||||
for _, child := range node.children {
|
||||
remove(child)
|
||||
}
|
||||
}
|
||||
remove(node)
|
||||
if parent == nil {
|
||||
*index = inheritancePolicyIndex{}
|
||||
} else {
|
||||
delete(parent.children, last)
|
||||
}
|
||||
for _, entry := range replacement.collections {
|
||||
scope.set(entry.destination, entry.policy)
|
||||
index.set(scope.collections[entry.destination.String()])
|
||||
}
|
||||
}
|
||||
111
internal/engine/inheritance_policy_index_test.go
Normal file
111
internal/engine/inheritance_policy_index_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInheritancePolicyIndexKeepsSemanticScopesAndOwnedTemplates(t *testing.T) {
|
||||
policy := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
nested := collectionPolicyScope{}
|
||||
nested.set(Keys("group", "base", "items"), policy)
|
||||
outer := policy
|
||||
record := newCollectionRecordPolicy()
|
||||
record.scope = nested
|
||||
outer.setRecordPolicyAt(0, record)
|
||||
scope := collectionPolicyScope{}
|
||||
scope.set(Keys("records"), outer)
|
||||
scope.set(Keys("numeric", "0", "items"), policy)
|
||||
index := newInheritancePolicyIndex(scope)
|
||||
|
||||
first := index.localize(Keys("records").Index(0).Key("group"))
|
||||
second := index.localize(Keys("records").Index(0).Key("group"))
|
||||
if len(first.collections) != 1 || len(second.collections) != 1 {
|
||||
t.Fatal("record-owned scope was not localized")
|
||||
}
|
||||
entry := first.collections["/base/items"]
|
||||
entry.policy.identityFields[0] = "changed"
|
||||
if second.collections["/base/items"].policy.identityFields[0] != "id" || nested.collections["/group/base/items"].policy.identityFields[0] != "id" {
|
||||
t.Fatal("localized policy shares mutable template state")
|
||||
}
|
||||
if len(index.localize(Keys("records", "0")).collections) != 0 || len(index.localize(Keys("numeric").Index(0)).collections) != 0 {
|
||||
t.Fatal("mapping keys and sequence indexes were conflated")
|
||||
}
|
||||
if len(index.localize(Keys("numeric", "0")).collections) != 1 {
|
||||
t.Fatal("numeric mapping key did not retain its policy")
|
||||
}
|
||||
left := index.localize(Keys("records"))
|
||||
right := index.localize(Keys("records"))
|
||||
leftOwner := left.collections[""].policy.recordOrder[0]
|
||||
rightOwner := right.collections[""].policy.recordOrder[0]
|
||||
if leftOwner == rightOwner || leftOwner == record.owner || rightOwner == record.owner {
|
||||
t.Fatal("independent templates share a record owner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritancePolicyIndexReplacesOnlyTheChangedScope(t *testing.T) {
|
||||
scope := collectionPolicyScope{}
|
||||
policy := collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true}
|
||||
for index := range 128 {
|
||||
scope.set(Keys(fmt.Sprintf("group-%03d", index), "pending"), policy)
|
||||
}
|
||||
index := newInheritancePolicyIndex(scope)
|
||||
changed := Keys("group-064")
|
||||
if len(index.absoluteScope(changed).collections) != 1 {
|
||||
t.Fatal("scope includes unrelated groups")
|
||||
}
|
||||
replacement := collectionPolicyScope{}
|
||||
replacement.set(changed.Key("introduced"), policy)
|
||||
index.replace(&scope, changed, replacement)
|
||||
if len(scope.collections) != 128 {
|
||||
t.Fatal("replacement lost unrelated pending declarations")
|
||||
}
|
||||
local := index.localize(changed)
|
||||
if _, found := local.collections["/introduced"]; !found || len(local.collections) != 1 {
|
||||
t.Fatal("retired policy survived or introduced policy was not indexed")
|
||||
}
|
||||
index.replace(&scope, changed, collectionPolicyScope{})
|
||||
if len(index.localize(changed).collections) != 0 || len(scope.collections) != 127 {
|
||||
t.Fatal("descendant retirement did not update the table and index together")
|
||||
}
|
||||
if len(index.localize(Keys("group-065")).collections) != 1 {
|
||||
t.Fatal("retirement changed another scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritanceWideIndependentYamlCollectionScopes(t *testing.T) {
|
||||
var body strings.Builder
|
||||
body.WriteString("configkit:\n sequences:\n")
|
||||
for index := range 64 {
|
||||
fmt.Fprintf(&body, " - {destination: [groups, group-%03d, base, items], key: id, mode: append}\n", index)
|
||||
}
|
||||
body.WriteString("groups:\n")
|
||||
for index := range 64 {
|
||||
fmt.Fprintf(&body, " group-%03d:\n base: {items: [{id: shared, value: parent}]}\n child: {extends: base, items: [{id: shared, value: child}]}\n", index)
|
||||
}
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type definition struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
type config struct {
|
||||
Groups map[string]map[string]definition `yaml:"groups"`
|
||||
}
|
||||
loaded, err := testLoad[config](Bytes("wide-inheritance", []byte(body.String())))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := loaded.Metadata()
|
||||
for name, group := range loaded.Config.Groups {
|
||||
if len(group["base"].Items) != 1 || group["base"].Items[0].Value != "parent" || len(group["child"].Items) != 1 || group["child"].Items[0].Value != "child" {
|
||||
t.Fatalf("independent scope %s composed incorrectly: %#v", name, group)
|
||||
}
|
||||
path := Keys("groups", name, "child", "items").Index(0).Key("value")
|
||||
if info, exists := metadata.Lookup(path); !exists || !info.Explicit || info.Origin.Layer != LayerBase {
|
||||
t.Fatalf("inherited destination lost source metadata at %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,10 @@ func analyzeInheritanceGraphWithCollectionRules(scope *document.Node, scopePath
|
||||
}}
|
||||
}
|
||||
|
||||
policyIndex := newInheritancePolicyIndex(policies)
|
||||
graph := &inheritanceGraph{
|
||||
scope: scope, scopePath: scopePath,
|
||||
collectionPolicies: cloneCollectionPolicyScopeForInheritance(policies), applicationPolicy: applicationPolicy,
|
||||
collectionPolicies: policyIndex.absoluteScope(scopePath), applicationPolicy: applicationPolicy,
|
||||
byName: make(map[string]int, len(scope.Mapping)),
|
||||
}
|
||||
entries := make(map[string]document.Entry, len(scope.Mapping))
|
||||
@@ -69,7 +70,7 @@ func analyzeInheritanceGraphWithCollectionRules(scope *document.Node, scopePath
|
||||
graph.byName[name] = index
|
||||
graph.nodes = append(graph.nodes, inheritanceTemplate{
|
||||
name: name, entry: entries[name], path: scopePath.Key(name),
|
||||
collectionPolicies: inheritancePolicyScopeAtPath(policies, scopePath.Key(name)),
|
||||
collectionPolicies: policyIndex.localize(scopePath.Key(name)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,6 +356,7 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
|
||||
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
|
||||
@@ -364,7 +366,7 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
|
||||
}
|
||||
continue
|
||||
}
|
||||
localPolicies := inheritancePolicyScopeAtPath(adapter.collectionPolicies(), slot.path)
|
||||
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
|
||||
|
||||
@@ -109,10 +109,11 @@ func (scope identifiedSequenceInheritanceScope) applicationPolicy() sequencePoli
|
||||
func (scope identifiedSequenceInheritanceScope) slotOverhead() int { return 0 }
|
||||
func (scope identifiedSequenceInheritanceScope) templates() []inheritanceExecutionTemplate {
|
||||
result := make([]inheritanceExecutionTemplate, len(scope.graph.nodes))
|
||||
policyIndex := newInheritancePolicyIndex(scope.policies)
|
||||
for index, node := range scope.graph.nodes {
|
||||
result[index] = inheritanceExecutionTemplate{
|
||||
entry: document.Entry{Value: node.item}, path: node.path,
|
||||
collectionPolicies: inheritancePolicyScopeAtPath(scope.policies, node.path),
|
||||
collectionPolicies: policyIndex.localize(node.path),
|
||||
parent: node.parent, hasParent: node.hasParent,
|
||||
hasDirective: node.directive != nil, cancelled: node.cancelled,
|
||||
directive: document.Entry{Value: node.directive}, directivePath: node.directivePath,
|
||||
|
||||
@@ -11,6 +11,7 @@ type inheritanceWalker struct {
|
||||
nodes int // logical non-directive nodes in the entire working tree
|
||||
origins map[string]Origin
|
||||
collectionPolicies collectionPolicyScope
|
||||
policyIndex *inheritancePolicyIndex
|
||||
applicationPolicy sequencePolicy
|
||||
limits projectionLimits
|
||||
retained *retainedNodeCounter
|
||||
@@ -66,6 +67,7 @@ func expandInheritanceWithCollectionRuleBudgets(input *document.Node, inputOrigi
|
||||
applicationPolicy: applicationPolicy,
|
||||
retained: newRetainedNodeCounter(historyLimit),
|
||||
}
|
||||
walker.policyIndex = newInheritancePolicyIndex(walker.collectionPolicies)
|
||||
if problem := walker.visit(&root, Path{}, true); problem != nil {
|
||||
return nil, []Diagnostic{*problem}
|
||||
}
|
||||
@@ -113,20 +115,13 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
|
||||
limits.nodes -= w.nodes - before
|
||||
}
|
||||
resolved, diagnostics := expandSiblingDefinitionsWithCollectionRules(
|
||||
*node, path, limits, w.origins, w.collectionPolicies, w.applicationPolicy, w.retained,
|
||||
*node, path, limits, w.origins, w.policyIndex.absoluteScope(path), w.applicationPolicy, w.retained,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return &diagnostics[0]
|
||||
}
|
||||
*node = resolved.root
|
||||
for pointer, entry := range w.collectionPolicies.collections {
|
||||
if pathHasPrefix(entry.destination, path) {
|
||||
delete(w.collectionPolicies.collections, pointer)
|
||||
}
|
||||
}
|
||||
for _, entry := range resolved.collectionPolicies.collections {
|
||||
w.collectionPolicies.set(entry.destination, entry.policy)
|
||||
}
|
||||
w.policyIndex.replace(&w.collectionPolicies, path, resolved.collectionPolicies)
|
||||
deleteOriginSubtree(w.origins, path, original)
|
||||
for pointer, origin := range resolved.origins {
|
||||
w.origins[pointer] = origin
|
||||
@@ -203,20 +198,13 @@ func (w *inheritanceWalker) expandIdentifiedSequence(node **document.Node, path
|
||||
limits.nodes -= w.nodes - before
|
||||
}
|
||||
resolved, diagnostics := applyIdentifiedInheritanceTemplates(
|
||||
graph, limits, w.origins, w.collectionPolicies, w.applicationPolicy, w.retained,
|
||||
graph, limits, w.origins, w.policyIndex.absoluteScope(path), w.applicationPolicy, w.retained,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return &diagnostics[0]
|
||||
}
|
||||
*node = resolved.root
|
||||
for pointer, entry := range w.collectionPolicies.collections {
|
||||
if pathHasPrefix(entry.destination, path) {
|
||||
delete(w.collectionPolicies.collections, pointer)
|
||||
}
|
||||
}
|
||||
for _, entry := range resolved.collectionPolicies.collections {
|
||||
w.collectionPolicies.set(entry.destination, entry.policy)
|
||||
}
|
||||
w.policyIndex.replace(&w.collectionPolicies, path, resolved.collectionPolicies)
|
||||
deleteOriginSubtree(w.origins, path, original)
|
||||
for pointer, origin := range resolved.origins {
|
||||
w.origins[pointer] = origin
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestPendingItemCompositionProjectsNestedIdentityDiagnostics(t *testing.T) {
|
||||
}}}
|
||||
|
||||
traversal := importTraversal{accounting: &importAccounting{}, limits: defaultImportLimits()}
|
||||
diagnostics := traversal.composePendingItems(&outer, sequencePolicy{})
|
||||
diagnostics := traversal.composePendingItems(&outer)
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func TestPendingItemCompositionProjectsNestedIdentityDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingItemCompositionAppliesApplicationIdentityConstraints(t *testing.T) {
|
||||
func TestPendingItemCompositionAppliesOuterApplicationIdentityConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
itemScope := acquisitionScope{contributions: []acquiredContribution{{
|
||||
@@ -116,15 +116,15 @@ func TestPendingItemCompositionAppliesApplicationIdentityConstraints(t *testing.
|
||||
destination: Keys("items"), pendingItem: &itemScope,
|
||||
}}}
|
||||
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector("prompts")}, identityFields: []string{"id"},
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||
}})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compile application identity: %#v", diagnostics)
|
||||
}
|
||||
|
||||
traversal := importTraversal{accounting: &importAccounting{}, limits: defaultImportLimits()}
|
||||
diagnostics = traversal.composePendingItems(&outer, policy)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/items/0/prompts/0/id" {
|
||||
_, diagnostics = composeAcquiredScopeWithPolicy(&outer, collectionPolicyScope{}, policy,
|
||||
newImportInclusionGraph(), nil, nil, nil, &importAccounting{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/items/0/id" {
|
||||
t.Fatalf("application identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ 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
|
||||
@@ -39,7 +41,9 @@ type Options[T any] struct {
|
||||
importDefaultsValidated bool
|
||||
}
|
||||
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays. For an
|
||||
// absent host input, Root is required and BaseDir may be omitted to use Root as
|
||||
// the import base. Native contexts still require both BaseDir and Root.
|
||||
type FilesystemContext struct {
|
||||
BaseDir string
|
||||
Root string
|
||||
@@ -198,6 +202,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
}
|
||||
accumulator.versionState = versionState
|
||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||
request.main.source = input.source
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(versionState.diagnostics...)
|
||||
reportedVersionDiagnostics := len(versionState.diagnostics)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
// assembledInput is the source/document handoff across schema and version checks.
|
||||
type assembledInput struct {
|
||||
source Source
|
||||
parsed parsedSource
|
||||
document *document.Node
|
||||
composition *composedDocument
|
||||
@@ -101,20 +102,41 @@ func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T
|
||||
}
|
||||
|
||||
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) {
|
||||
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 = options.placementMode
|
||||
}
|
||||
return assembledInput{source: resolved, sources: sourceInfos(failedSource), versionState: versionState, accounting: accounting}, diagnostics
|
||||
}
|
||||
selectedSource = resolved
|
||||
if options.Filesystem != nil {
|
||||
if diagnostics := filesystemOptionDiagnostics(selectedSource, options.Filesystem); len(diagnostics) != 0 {
|
||||
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
|
||||
}
|
||||
}
|
||||
if selectedSource.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
|
||||
}
|
||||
}
|
||||
limits := defaultImportLimits()
|
||||
parseLimits := defaultParseLimits()
|
||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
||||
parsed, diagnostics := parseSource(selectedSource, sourceID, 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}
|
||||
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 source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
|
||||
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))
|
||||
@@ -200,11 +222,11 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
}
|
||||
if options.Imports {
|
||||
if options.Imports && selectedSource.kind != sourceNone {
|
||||
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)
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(selectedSource, 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)
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
@@ -218,8 +240,8 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
if versionProcessing != nil {
|
||||
input.document = versionProcessing.payload
|
||||
}
|
||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
if options.Filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(options.Overlay)) {
|
||||
if diagnostic := validateFilesystemContext(selectedSource, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +273,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
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 {
|
||||
if diagnostic := registerAcquiredSource(selectedSource, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
accounting.sources++
|
||||
@@ -259,7 +281,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
accounting.nodes += rawNodes + placementNodes
|
||||
}
|
||||
if !activeOverlay(options.Overlay) || options.Imports {
|
||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
|
||||
contexts, diagnostic := contextsForAcquiredSources(selectedSource, options.Filesystem, parsed.source, input.sources)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
|
||||
79
internal/engine/metadata_locations_test.go
Normal file
79
internal/engine/metadata_locations_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestMetadataLocationsAcrossBroadAuthoredAndDefaultMappings(t *testing.T) {
|
||||
type config map[string]map[string]string
|
||||
defaults := config{}
|
||||
var body strings.Builder
|
||||
for index := range 96 {
|
||||
name := fmt.Sprintf("authored-%03d", index)
|
||||
fmt.Fprintf(&body, "%s: {present: source}\n", name)
|
||||
defaults[name] = map[string]string{"absent": "default"}
|
||||
defaults[fmt.Sprintf("default-%03d", index)] = map[string]string{"absent": "default"}
|
||||
}
|
||||
input := parseDocumentForDecode(t, body.String())
|
||||
decoded, issues := decodeConfiguration(mustSchemaFor[config](t), reflect.ValueOf(defaults), input)
|
||||
if len(issues) != 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
located := documentWithMetadataLocations(decoded.final, metadataFromDecoded(decoded), input)
|
||||
inputIndex := buildDocumentLookup(input)
|
||||
locatedIndex := buildDocumentLookup(located)
|
||||
for index := range 96 {
|
||||
authored := Keys(fmt.Sprintf("authored-%03d", index))
|
||||
mapping, _ := documentAtPathWithLookup(input, authored, inputIndex)
|
||||
present, _ := documentAtPathWithLookup(input, authored.Key("present"), inputIndex)
|
||||
for _, test := range []struct {
|
||||
path Path
|
||||
want document.Location
|
||||
}{
|
||||
{authored.Key("present"), present.Location},
|
||||
{authored.Key("absent"), mapping.Location},
|
||||
{Keys(fmt.Sprintf("default-%03d", index), "absent"), input.Location},
|
||||
} {
|
||||
node, found := documentAtPathWithLookup(located, test.path, locatedIndex)
|
||||
if !found || node.Location != test.want {
|
||||
t.Fatalf("location at %s = %#v, want %#v", test.path, node, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
walkDocument(located, Path{}, func(_ Path, node *document.Node) {
|
||||
for _, entry := range node.Mapping {
|
||||
if entry.KeyLocation != entry.Value.Location || entry.ValueLocation != entry.Value.Location {
|
||||
t.Fatal("entry and value locations disagree")
|
||||
}
|
||||
}
|
||||
})
|
||||
before := decoded.final.Mapping[0].Value.Mapping[0].Value.Text
|
||||
located.Mapping[0].Value.Mapping[0].Value.Text = "changed snapshot"
|
||||
if decoded.final.Mapping[0].Value.Mapping[0].Value.Text != before || input.Mapping[0].Value.Mapping[0].Value.Text != "source" {
|
||||
t.Fatal("location projection changed a borrowed input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexedNearestLocationsPreserveMissingAndSyntheticCoordinates(t *testing.T) {
|
||||
root := parseDocumentForDecode(t, "mapping: {'0': value}\nitems: [{value: source}]\nscalar: stop\n")
|
||||
lookup := buildDocumentLookup(root)
|
||||
for _, path := range []Path{Path{}, Keys("missing"), Keys("mapping", "missing"), Keys("mapping", "0"), Keys("mapping").Index(0), Keys("items").Index(4), Keys("items").Index(0).Key("missing"), Keys("scalar", "child"), Path{}.Index(-1)} {
|
||||
want, wantOK := nearestDocumentLocation(root, path)
|
||||
got, gotOK := nearestDocumentLocationWithLookup(root, path, lookup)
|
||||
if got != want || gotOK != wantOK {
|
||||
t.Fatalf("nearest location changed for %s: %#v/%t, want %#v/%t", path, got, gotOK, want, wantOK)
|
||||
}
|
||||
}
|
||||
final := parseDocumentForDecode(t, "value: cli\n")
|
||||
synthetic := Location{SourceID: "cli-1"}
|
||||
metadata := Metadata{fields: map[string]FieldInfo{"/value": {Origin: Origin{Layer: LayerCLI, Locations: []Location{synthetic}}}}}
|
||||
located := documentWithMetadataLocations(final, metadata, root)
|
||||
if location := located.Mapping[0].Value.Location; location.SourceID != "cli-1" || location.Line != 0 || location.Column != 0 {
|
||||
t.Fatalf("synthetic CLI location was replaced by fallback: %#v", location)
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,18 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
if options.Filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||
}
|
||||
if source.kind == sourceNone && options.Imports {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
||||
if source.kind == sourceSearch {
|
||||
for _, issue := range source.searchIssues {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||
}
|
||||
if source.search == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "configuration search is invalid"))
|
||||
} else {
|
||||
diagnostics = append(diagnostics, validateSearch(*source.search, source.searchApp)...)
|
||||
}
|
||||
}
|
||||
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
||||
if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||
}
|
||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||
|
||||
@@ -138,8 +138,12 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
for key := range declarationsByPath {
|
||||
registered[key] = true
|
||||
}
|
||||
var overlayLookup *documentLookup
|
||||
if len(result.collectionPolicies.collections) != 0 {
|
||||
overlayLookup = buildDocumentLookup(overlay)
|
||||
}
|
||||
for key := range result.collectionPolicies.collections {
|
||||
if node, found := documentAtPath(overlay, result.collectionPolicies.collections[key].destination); found && node != nil {
|
||||
if node, found := documentAtPathWithLookup(overlay, result.collectionPolicies.collections[key].destination, overlayLookup); found && node != nil {
|
||||
registered[key] = true
|
||||
registeredPaths[key] = result.collectionPolicies.collections[key].destination.clone()
|
||||
}
|
||||
@@ -210,6 +214,9 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
return nil, canonicalDiagnostics(combinationDiagnostics)
|
||||
}
|
||||
|
||||
// Values are now complete. Index lazily for declaration-only checks;
|
||||
// this lookup is never retained across the value-changing merge above.
|
||||
var retainedLookup *documentLookup
|
||||
for key, grouped := range declarationsByPath {
|
||||
if policyValues[key] {
|
||||
continue
|
||||
@@ -226,7 +233,10 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
if found {
|
||||
lowerPolicy = &lower
|
||||
}
|
||||
retained, _ := documentAtPath(assembly.current.root, path)
|
||||
if retainedLookup == nil {
|
||||
retainedLookup = buildDocumentLookup(assembly.current.root)
|
||||
}
|
||||
retained, _ := documentAtPathWithLookup(assembly.current.root, path, retainedLookup)
|
||||
if retained != nil && retained.Kind == document.Sequence && len(current.effectiveIdentityFields) != 0 {
|
||||
if _, issues := validateSequenceIdentity(retained, path, current.effectiveIdentityFields, sequenceIdentityDiagnosticCodes{invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict}); len(issues) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||
|
||||
@@ -171,16 +171,23 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb
|
||||
if len(fallback) != 0 {
|
||||
fallbackRoot = fallback[0]
|
||||
}
|
||||
var lookup *documentLookup
|
||||
fallbackLocation := func(path Path) (document.Location, bool) {
|
||||
if lookup == nil {
|
||||
lookup = buildDocumentLookup(fallbackRoot)
|
||||
}
|
||||
return nearestDocumentLocationWithLookup(fallbackRoot, path, lookup)
|
||||
}
|
||||
var visit func(*document.Node, Path)
|
||||
visit = func(node *document.Node, path Path) {
|
||||
pointer := path.String()
|
||||
if info, exists := metadata.fields[pointer]; exists {
|
||||
if location, ok := metadataFieldLocation(metadata, pointer, info.Origin); ok {
|
||||
node.Location = location
|
||||
} else if location, ok := nearestDocumentLocation(fallbackRoot, path); ok {
|
||||
} else if location, ok := fallbackLocation(path); ok {
|
||||
node.Location = location
|
||||
}
|
||||
} else if location, ok := nearestDocumentLocation(fallbackRoot, path); ok {
|
||||
} else if location, ok := fallbackLocation(path); ok {
|
||||
node.Location = location
|
||||
}
|
||||
switch node.Kind {
|
||||
@@ -192,11 +199,11 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb
|
||||
if location, ok := metadataFieldLocation(metadata, childPath.String(), info.Origin); ok {
|
||||
entry.KeyLocation = location
|
||||
entry.ValueLocation = location
|
||||
} else if location, ok := nearestDocumentLocation(fallbackRoot, childPath); ok {
|
||||
} else if location, ok := fallbackLocation(childPath); ok {
|
||||
entry.KeyLocation = location
|
||||
entry.ValueLocation = location
|
||||
}
|
||||
} else if location, ok := nearestDocumentLocation(fallbackRoot, childPath); ok {
|
||||
} else if location, ok := fallbackLocation(childPath); ok {
|
||||
entry.KeyLocation = location
|
||||
entry.ValueLocation = location
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
@@ -171,17 +170,16 @@ func updateOverridePresence(presence map[string]inputPresence, existing, incomin
|
||||
return
|
||||
}
|
||||
|
||||
deletePresencePathSubtree(presence, path)
|
||||
deletePresencePathSubtree(presence, existing, path)
|
||||
collectPresence(incoming, path, presence)
|
||||
}
|
||||
|
||||
func deletePresencePathSubtree(presence map[string]inputPresence, path Path) {
|
||||
pointer := path.String()
|
||||
for candidate := range presence {
|
||||
if candidate == pointer || strings.HasPrefix(candidate, pointer+"/") {
|
||||
delete(presence, candidate)
|
||||
}
|
||||
}
|
||||
func deletePresencePathSubtree(presence map[string]inputPresence, existing *document.Node, path Path) {
|
||||
// The typed pre-CLI tree contains every effective descendant. Visit only
|
||||
// the replaced subtree rather than searching unrelated metadata entries.
|
||||
walkDocument(existing, path, func(path Path, _ *document.Node) {
|
||||
delete(presence, path.String())
|
||||
})
|
||||
}
|
||||
|
||||
func updateOverrideOrigins(origins map[string]Origin, existing, incoming *document.Node, path Path, location document.Location) {
|
||||
@@ -200,12 +198,9 @@ func updateOverrideOrigins(origins map[string]Origin, existing, incoming *docume
|
||||
return
|
||||
}
|
||||
|
||||
pointer := path.String()
|
||||
for candidate := range origins {
|
||||
if candidate == pointer || strings.HasPrefix(candidate, pointer+"/") {
|
||||
delete(origins, candidate)
|
||||
}
|
||||
}
|
||||
walkDocument(existing, path, func(path Path, _ *document.Node) {
|
||||
delete(origins, path.String())
|
||||
})
|
||||
collectOverlayOrigins(origins, incoming, path, LayerCLI)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
82
internal/engine/override_metadata_test.go
Normal file
82
internal/engine/override_metadata_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBroadPartialCLIOverrideRetainsUnchangedMetadata(t *testing.T) {
|
||||
type child struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
type record struct {
|
||||
Count int `yaml:"count"`
|
||||
Zero int `yaml:"zero"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Items []child `yaml:"items"`
|
||||
Maybe *child `yaml:"maybe"`
|
||||
}
|
||||
type config struct {
|
||||
Records map[string]record `yaml:"records"`
|
||||
}
|
||||
var body strings.Builder
|
||||
body.WriteString("records:\n")
|
||||
updates := make(map[string]any)
|
||||
for index := range 96 {
|
||||
name := fmt.Sprintf("key/%d~", index)
|
||||
fmt.Fprintf(&body, " %s: {count: 9, zero: 0, enabled: false, items: [{name: old}], maybe: {name: old}}\n", name)
|
||||
if index%2 == 0 {
|
||||
updates[name] = map[string]any{"count": 0, "items": []any{}, "maybe": nil}
|
||||
}
|
||||
}
|
||||
input := parseDocumentForDecode(t, body.String())
|
||||
schema := mustSchemaFor[config](t)
|
||||
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(config{}), input)
|
||||
if len(issues) != 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
batch, diagnostics := preparePostStructuralOverrides(overridePreparationInput{root: input}, nil,
|
||||
[]Override{{Path: Keys("records"), Value: updates, Flag: "--records"}}, nil, defaultImportLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
result, issues := applyDecodedOverrides(schema, decoded, batch, decodeContext{})
|
||||
if len(issues) != 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
for index := range 96 {
|
||||
name := fmt.Sprintf("key/%d~", index)
|
||||
path := Keys("records", name)
|
||||
value := result.value.Interface().(config).Records[name]
|
||||
for _, key := range []string{"zero", "enabled"} {
|
||||
pointer := path.Key(key).String()
|
||||
if _, explicit := result.presence[pointer]; !explicit || result.origins[pointer].Layer != LayerBase {
|
||||
t.Fatalf("retained explicit field lost metadata at %s", pointer)
|
||||
}
|
||||
}
|
||||
if index%2 != 0 {
|
||||
if value.Count != 9 || len(value.Items) != 1 || value.Maybe == nil || result.origins[path.Key("count").String()].Layer != LayerBase {
|
||||
t.Fatalf("omitted record changed at %s", path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if value.Count != 0 || len(value.Items) != 0 || value.Maybe != nil {
|
||||
t.Fatalf("CLI replacement failed at %s: %#v", path, value)
|
||||
}
|
||||
for _, removed := range []Path{path.Key("items").Index(0), path.Key("items").Index(0).Key("name"), path.Key("maybe").Key("name")} {
|
||||
_, present := result.presence[removed.String()]
|
||||
_, origin := result.origins[removed.String()]
|
||||
if present || origin {
|
||||
t.Fatalf("removed descendant kept metadata at %s", removed)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"count", "items", "maybe"} {
|
||||
pointer := path.Key(key).String()
|
||||
if presence, ok := result.presence[pointer]; !ok || presence.location.SourceID != batch.overrides[0].source.ID || result.origins[pointer].Layer != LayerCLI {
|
||||
t.Fatalf("CLI field lost metadata at %s", pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,24 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex
|
||||
}
|
||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
|
||||
}
|
||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||
var rootPath string
|
||||
if source.kind == sourceNone {
|
||||
if context == nil {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")
|
||||
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||
}
|
||||
var err error
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||
}
|
||||
} else {
|
||||
var diagnostic *Diagnostic
|
||||
rootPath, _, _, diagnostic = importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(rootPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
package engine
|
||||
|
||||
type sequenceItemProjectionIndex struct {
|
||||
outputByInput []int
|
||||
inputByOutput []int
|
||||
outputByInput map[int]int
|
||||
inputByOutput map[int]int
|
||||
}
|
||||
|
||||
func newSequenceItemProjectionIndex(projections []sequenceItemProjection) sequenceItemProjectionIndex {
|
||||
maxInput := -1
|
||||
maxOutput := -1
|
||||
for _, projection := range projections {
|
||||
if projection.inputIndex > maxInput {
|
||||
maxInput = projection.inputIndex
|
||||
}
|
||||
if projection.outputIndex > maxOutput {
|
||||
maxOutput = projection.outputIndex
|
||||
}
|
||||
}
|
||||
index := sequenceItemProjectionIndex{
|
||||
outputByInput: make([]int, maxInput+1),
|
||||
inputByOutput: make([]int, maxOutput+1),
|
||||
}
|
||||
for input := range index.outputByInput {
|
||||
index.outputByInput[input] = -1
|
||||
}
|
||||
for output := range index.inputByOutput {
|
||||
index.inputByOutput[output] = -1
|
||||
outputByInput: make(map[int]int, len(projections)),
|
||||
inputByOutput: make(map[int]int, len(projections)),
|
||||
}
|
||||
for _, projection := range projections {
|
||||
if projection.inputIndex >= 0 && projection.inputIndex < len(index.outputByInput) && projection.outputIndex >= 0 && projection.outputIndex < len(index.inputByOutput) {
|
||||
if projection.inputIndex >= 0 && projection.outputIndex >= 0 {
|
||||
index.outputByInput[projection.inputIndex] = projection.outputIndex
|
||||
index.inputByOutput[projection.outputIndex] = projection.inputIndex
|
||||
}
|
||||
@@ -36,15 +20,11 @@ func newSequenceItemProjectionIndex(projections []sequenceItemProjection) sequen
|
||||
}
|
||||
|
||||
func (index sequenceItemProjectionIndex) outputForInput(inputIndex int) (int, bool) {
|
||||
if inputIndex < 0 || inputIndex >= len(index.outputByInput) || index.outputByInput[inputIndex] < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return index.outputByInput[inputIndex], true
|
||||
output, found := index.outputByInput[inputIndex]
|
||||
return output, found
|
||||
}
|
||||
|
||||
func (index sequenceItemProjectionIndex) inputForOutput(outputIndex int) (int, bool) {
|
||||
if outputIndex < 0 || outputIndex >= len(index.inputByOutput) || index.inputByOutput[outputIndex] < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return index.inputByOutput[outputIndex], true
|
||||
input, found := index.inputByOutput[outputIndex]
|
||||
return input, found
|
||||
}
|
||||
|
||||
49
internal/engine/sequence_projection_test.go
Normal file
49
internal/engine/sequence_projection_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSparseSequenceProjectionIndexesOnlyAssignedPositions(t *testing.T) {
|
||||
index := newSequenceItemProjectionIndex([]sequenceItemProjection{
|
||||
{inputIndex: 0, outputIndex: 9000}, {inputIndex: 3000, outputIndex: 2},
|
||||
})
|
||||
if len(index.outputByInput) != 2 || len(index.inputByOutput) != 2 {
|
||||
t.Fatal("sparse projection retained unassigned positions")
|
||||
}
|
||||
for input, output := range map[int]int{0: 9000, 3000: 2} {
|
||||
if got, found := index.outputForInput(input); !found || got != output {
|
||||
t.Fatalf("input %d = %d, %t", input, got, found)
|
||||
}
|
||||
if got, found := index.inputForOutput(output); !found || got != input {
|
||||
t.Fatalf("output %d = %d, %t", output, got, found)
|
||||
}
|
||||
}
|
||||
for _, position := range []int{-1, 1, 10000} {
|
||||
if _, found := index.outputForInput(position); found {
|
||||
t.Fatalf("unassigned input %d exists", position)
|
||||
}
|
||||
if _, found := index.inputForOutput(position); found {
|
||||
t.Fatalf("unassigned output %d exists", position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContiguousSequenceRangesKeepArithmeticLookup(t *testing.T) {
|
||||
ranges := make([]sequenceRange, 128)
|
||||
for position := range ranges {
|
||||
ranges[position] = sequenceRange{sourceIndex: position, path: Keys("items"), start: position, end: position + 1}
|
||||
}
|
||||
for _, assigned := range indexSequenceRanges(ranges) {
|
||||
if len(assigned.projection.outputByInput) != 0 || len(assigned.projection.inputByOutput) != 0 {
|
||||
t.Fatal("contiguous range materialized a projection index")
|
||||
}
|
||||
if output, found := assigned.outputForInput(0); !found || output != assigned.start {
|
||||
t.Fatal("arithmetic forward lookup lost its offset")
|
||||
}
|
||||
if input, found := assigned.inputForOutput(assigned.start); !found || input != 0 {
|
||||
t.Fatal("arithmetic reverse lookup lost its offset")
|
||||
}
|
||||
if _, found := assigned.inputForOutput(assigned.end); found {
|
||||
t.Fatal("range includes its exclusive end")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,14 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
|
||||
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
|
||||
if source.kind == sourceNone && context.BaseDir == "" {
|
||||
basePath = rootPath
|
||||
} else {
|
||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return nil, &value
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,10 @@ func validationMutationDiagnostic(path Path) Diagnostic {
|
||||
}
|
||||
|
||||
func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) {
|
||||
return nearestDocumentLocationWithLookup(root, path, nil)
|
||||
}
|
||||
|
||||
func nearestDocumentLocationWithLookup(root *document.Node, path Path, lookup *documentLookup) (document.Location, bool) {
|
||||
if root == nil || !path.valid() {
|
||||
return document.Location{}, false
|
||||
}
|
||||
@@ -135,18 +139,22 @@ func nearestDocumentLocation(root *document.Node, path Path) (document.Location,
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return nearest, locationKnown(nearest)
|
||||
}
|
||||
found := false
|
||||
for _, entry := range current.Mapping {
|
||||
if entry.Key == segment.key {
|
||||
current = entry.Value
|
||||
nearest = current.Location
|
||||
found = true
|
||||
break
|
||||
position := -1
|
||||
if lookup != nil {
|
||||
position = lookup.find(current, segment.key)
|
||||
} else {
|
||||
for index, entry := range current.Mapping {
|
||||
if entry.Key == segment.key {
|
||||
position = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if position < 0 {
|
||||
return nearest, locationKnown(nearest)
|
||||
}
|
||||
current = current.Mapping[position].Value
|
||||
nearest = current.Location
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index >= len(current.Sequence) {
|
||||
|
||||
14
lifecycle.go
14
lifecycle.go
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user