127 Commits

Author SHA1 Message Date
f9ec66e867 Adopt shared history ownership in the interface roadmap 2026-10-26 12:36:17 +00:00
be5cdeb502 Plan interface contract remediation 2026-10-26 02:40:21 +00:00
1f64673d0d Record API contract audit findings 2026-10-26 01:36:20 +00:00
01b08e76a3 Retire the completed consolidated audit 2026-10-26 01:25:35 +00:00
453da684d7 Write release notes for v0.10.2 2026-10-26 01:19:38 +00:00
fb6c254d38 Mark import simplification roadmap complete 2026-10-26 00:21:57 +00:00
a71413ce67 Document import and facade boundaries 2026-10-26 00:21:40 +00:00
d66338a1c5 Construct placement directly across the facade 2026-10-26 00:18:12 +00:00
643f43ce6a Snapshot import defaults at load preflight 2026-10-26 00:13:34 +00:00
c25cf39d19 Consolidate engine import defaults 2026-10-26 00:10:07 +00:00
7d18c93339 Extract patterned glob entry preparation 2026-10-26 00:08:23 +00:00
b1d89810ca Plan discovery and facade boundary refactoring 2026-10-26 00:03:02 +00:00
bb5540b62e Plan import discovery and facade simplification 2026-10-25 23:55:45 +00:00
80cf23e710 Mark the implementation roadmap complete 2026-10-25 23:26:29 +00:00
ac48557535 Document and verify the remediation sprint 2026-10-25 23:26:10 +00:00
b4af900c10 Index secret failure locations lazily 2026-10-25 23:21:29 +00:00
f132cd6468 Index inherited compound identity restoration 2026-10-25 23:17:59 +00:00
75e209eb3b Index tier history sources lazily 2026-10-25 23:13:00 +00:00
1dd94a3cc6 Share sequence history construction 2026-10-25 23:09:41 +00:00
d172a5e46a Apply sparse collection policy across overlays 2026-10-25 23:05:23 +00:00
3a1b6ff355 Borrow lower collection policies during strict composition 2026-10-25 22:59:03 +00:00
68383f34de Index collection policy retirement 2026-10-25 22:49:02 +00:00
549fca239d Separate collection overlay responsibilities 2026-10-25 22:42:21 +00:00
883244936c Share layered tier application 2026-10-25 22:37:46 +00:00
a8ae2e9d5e Clarify load context and inheritance handoffs 2026-10-25 22:34:52 +00:00
f18582106b Isolate standalone input completion 2026-10-25 22:32:52 +00:00
d8bf6abc2b Isolate source directive processing 2026-10-25 22:30:46 +00:00
4bf940c579 Use deferred acquisition consistently 2026-10-25 22:28:35 +00:00
4fd3bee986 Strengthen composition and import selection tests 2026-10-25 22:21:51 +00:00
6f6130fd12 Use production directive extraction in import tests 2026-10-25 22:19:38 +00:00
3122e2f32d Remove unread private transport members 2026-10-25 22:18:12 +00:00
e4a5ecb4be Remove disconnected facade clone scaffolding 2026-10-25 22:13:00 +00:00
826062aa17 Test public historical path enumeration 2026-10-25 22:09:55 +00:00
08f2a8f3e9 Cache approved dynamic descriptors per operation 2026-10-25 22:07:37 +00:00
12d350a292 Enforce the retained history node limit 2026-10-25 22:04:54 +00:00
4fcff5f453 Prepare inspection history before publication 2026-10-25 21:53:24 +00:00
247a12bc50 Remove unused combination evidence and helpers 2026-10-25 21:49:52 +00:00
119b42c810 Remove unused history document copies 2026-10-25 21:47:53 +00:00
0f9ecf12ed Share link dependency evaluation with host access 2026-10-25 21:45:33 +00:00
7bf2f0c687 Memoize completed native link dependencies 2026-10-25 21:43:26 +00:00
8aba43da38 Preserve directory traversal failure phases 2026-10-25 21:33:36 +00:00
877c497325 Close owned import roots during panic unwinding 2026-10-25 21:27:47 +00:00
08a8c25542 Close callback contexts when callbacks panic 2026-10-25 21:25:57 +00:00
ea27f17f5c Check projection budgets before container allocation 2026-10-25 21:25:00 +00:00
ae3408a096 Distinguish finite slice views from cycles 2026-10-25 21:22:01 +00:00
deb9aa5c69 Roll back failed dynamic descriptor builds 2026-10-25 21:20:41 +00:00
2736ddfbf2 Terminate pointer-only descriptor traversal 2026-10-25 21:18:35 +00:00
7889ebddfb Plan the audit remediation sprint 2026-10-25 20:52:11 +00:00
4a5316b7fc Describe the audit remediation target and dependencies 2026-10-25 20:25:18 +00:00
e1e94eeec7 Settle audit refactoring scope and defer follow-up work 2026-10-25 20:17:47 +00:00
176e9a5d90 Select localized audit efficiency improvements 2026-10-25 20:13:57 +00:00
23c1976ef1 Settle the initial audit remediation scope 2026-10-25 20:12:12 +00:00
aab79d9508 Consolidate repository audit findings for review 2026-10-25 20:07:46 +00:00
ecd5d5d460 Record dead code and obsolete functionality audit 2026-10-25 17:22:25 +00:00
b1c278b38b Record test coverage and effectiveness audit findings 2026-10-25 17:07:39 +00:00
eb5a82f1f3 Record security audit findings 2026-10-25 16:53:57 +00:00
b30c9f167d Record error handling audit findings 2026-10-25 16:44:08 +00:00
5925066d35 Record resource management audit findings 2026-10-25 16:37:05 +00:00
0cca633a67 Record concurrency and lifetime audit results 2026-10-25 16:25:10 +00:00
a80273433e Record maintainability and complexity audit findings 2026-10-25 16:18:07 +00:00
320d0c5f43 Record algorithmic efficiency audit findings 2026-10-25 16:05:49 +00:00
5098b56655 Record functional correctness audit findings 2026-10-25 15:52:43 +00:00
47944d22d1 Update ReleaseKit to v0.4.2 2026-10-25 15:37:32 +00:00
50199eb0af Retire completed consolidation roadmap documents 2026-10-25 15:09:34 +00:00
34cbc0d63b Document ConfigKit v0.10.1 2026-10-25 15:07:06 +00:00
5e12b2d360 Retire the completed clone traversal review 2026-10-25 14:58:56 +00:00
8dfaf44d1d Index import dependencies for local scope scheduling 2026-10-25 14:41:07 +00:00
64be8b4df5 Keep import selection evidence with its declaring source 2026-10-25 14:36:39 +00:00
e585decdb2 Reuse validated filesystem facts through acquisition 2026-10-25 14:33:17 +00:00
6440a500f7 Record import bookkeeping scalability findings 2026-10-25 14:29:40 +00:00
d898cb193d Record the remaining context consolidation gap 2026-10-25 14:00:29 +00:00
557e006a4a Complete consolidation roadmap validation 2026-10-25 13:51:36 +00:00
3fd83b6b2b Document consumer and internal loading flows 2026-10-25 13:49:44 +00:00
0fbce5d141 Separate schema field discovery and assembly 2026-10-25 13:46:51 +00:00
ac4ce242a2 Separate reflection projection handlers 2026-10-25 13:45:27 +00:00
fa6e876ab5 Extract inheritance accounting and finalization 2026-10-25 13:43:26 +00:00
c5275df1fc Return inheritance combination evidence together 2026-10-25 13:41:41 +00:00
f404168900 Extract inheritance input preparation 2026-10-25 13:32:01 +00:00
729c6fb5d5 Extract imported source admission from traversal 2026-10-25 13:28:43 +00:00
d314a269d5 Separate directory entry admission from traversal 2026-10-25 13:25:37 +00:00
2c3eb3308b Extract import declaration parsing helpers 2026-10-25 13:23:09 +00:00
6bc625b68f Split import acquisition and projection responsibilities 2026-10-25 13:21:29 +00:00
b915dd776a Consolidate selected overlay acquisition 2026-10-25 13:18:54 +00:00
9a64a65b6c Carry resolved filesystem facts through loads 2026-10-25 13:13:47 +00:00
6158e9db3b Separate filesystem context from access 2026-10-25 13:09:44 +00:00
0d149e51f0 Wire load lifecycle through input acquisition 2026-10-25 13:07:16 +00:00
bd51b9c3f2 Consolidate import request ownership 2026-10-25 13:02:46 +00:00
d05426d8a2 Introduce non-generic input acquisition requests 2026-10-25 12:55:04 +00:00
3f0efcb4ff Normalize ordinary and grouped load preflight 2026-10-25 12:52:50 +00:00
19f5c2a2d5 Remove unused reverse source conversions 2026-10-25 12:50:11 +00:00
455a4a355d Map ConfigKit consolidation callers and coverage 2026-10-25 12:37:33 +00:00
8ab1568352 Plan the ConfigKit consolidation sprint 2026-10-25 12:14:01 +00:00
8a99e61070 Defer clone traversal review to future work 2026-10-25 11:54:46 +00:00
de7513d488 Settle retaining the public loading APIs 2026-10-24 23:07:09 +00:00
26771dc03e Consolidate refactoring roadmaps and record open questions 2026-10-24 22:04:26 +00:00
fbaaaf18cd Draft the ConfigKit consolidation roadmap toward 1.0 2026-10-24 21:51:29 +00:00
c9574ef3c1 Record complexity audit findings 2026-10-24 19:54:13 +00:00
5959537d1a Prepare the ConfigKit v0.10.0 release 2026-10-24 19:45:33 +00:00
0369111d30 Resolve Windows rooted search paths with Go 2026-10-24 19:24:30 +00:00
2fedb0307f Use Go rules for Windows filename components 2026-10-24 19:20:54 +00:00
087050fa25 Isolate user configuration discovery tests by platform 2026-10-24 19:18:13 +00:00
1ac1f8bc9e Preserve failing search candidate diagnostics 2026-10-24 19:16:50 +00:00
4dc3e70877 Validate search filesystem context after selection 2026-10-24 19:13:35 +00:00
f64497cb13 Record discovery algorithm audit findings 2026-10-24 19:03:04 +00:00
68cf5c489b Record configuration discovery review findings 2026-10-24 17:52:20 +00:00
e50fee2ca5 Complete the configuration discovery implementation plan 2026-10-24 16:26:09 +00:00
eec214944c Align discovery ownership and contributor documentation 2026-10-24 16:24:57 +00:00
4f2c113237 Extend discovery coverage across consumer workflows 2026-10-24 16:20:53 +00:00
2d93a5c703 Expose configuration search through the public facade 2026-10-24 16:16:07 +00:00
2f6c1eca00 Integrate configuration searches into engine loading 2026-10-24 16:11:03 +00:00
8642337966 Load ordinary overlays without a main source 2026-10-24 15:59:39 +00:00
53ce6c63d8 Allow absent inputs to use import contexts 2026-10-24 15:55:10 +00:00
2f0900535d Select configuration files through host rules 2026-10-24 15:46:36 +00:00
4748313105 Expand ordered configuration search candidates 2026-10-24 15:43:10 +00:00
e2634c8815 Define immutable configuration searches 2026-10-24 15:39:59 +00:00
424c89780e Plan configuration discovery implementation 2026-10-24 15:04:02 +00:00
92309ddcc3 Update the discovery roadmap baseline 2026-10-24 14:54:56 +00:00
7ff204bf3d Clarify v0.9.0 import and collection migration guidance 2026-10-24 14:48:21 +00:00
b3ec860fac Document the ConfigKit v0.9.0 release 2026-10-24 14:44:58 +00:00
435dea102f Retire the completed remediation record 2026-10-24 14:41:56 +00:00
ea46001f78 Reuse document indexes for collection policy checks 2026-10-24 14:17:09 +00:00
a0e5693d07 Share indexed fallback locations during effective projection 2026-10-24 14:13:52 +00:00
5c5f2af6cc Limit inheritance policy work to the relevant scope 2026-10-24 14:10:44 +00:00
8253993005 Delete CLI metadata through the replaced subtree 2026-10-24 14:05:42 +00:00
d0848290da Keep sequence projection storage proportional to assigned items 2026-10-24 14:04:26 +00:00
e94a0642ff Keep global registrations out of item-local composition 2026-10-24 13:59:34 +00:00
571d063675 Retire the completed sequence roadmap and implementation plan 2026-10-24 13:56:19 +00:00
153 changed files with 11358 additions and 7328 deletions

View File

@@ -45,6 +45,11 @@ ConfigKit does not search conventional application paths implicitly. See the
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
[ADR 0016](docs/adr/0016-independent-import-controls.md). [ADR 0016](docs/adr/0016-independent-import-controls.md).
Main-file discovery is explicit too: applications choose and order `Search`
descriptions and pass `SearchConfig` to the existing load APIs. See the
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
```go ```go
type Config struct { type Config struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`

View File

@@ -52,16 +52,6 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)), ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
} }
} }
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
result := engine.SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination),
Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck),
}
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
}
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection { func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
if values == nil { if values == nil {
return nil return nil
@@ -76,15 +66,14 @@ func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []Impo
return result return result
} }
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade { func toEngineOverrides(values []Override) []engine.Override {
if values == nil { if values == nil {
return nil return nil
} }
result := make([]engine.ImportSelectionForFacade, len(values)) result := make([]engine.Override, len(values))
for index, value := range values { for index, value := range values {
result[index] = engine.ImportSelectionForFacade{ result[index] = engine.Override{
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location), Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
Selector: string(value.Selector), Outcome: string(value.Outcome),
} }
} }
return result return result
@@ -92,31 +81,14 @@ func toEngineImportSelections(values []ImportSelection) []engine.ImportSelection
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults { func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
return engine.ImportDefaults{ return engine.ImportDefaults{
Required: cloneImportDefaultBool(value.Required), Required: value.Required,
Follow: cloneImportDefaultBool(value.Follow), Follow: value.Follow,
Hidden: cloneImportDefaultBool(value.Hidden), Hidden: value.Hidden,
Recursive: cloneImportDefaultBool(value.Recursive), Recursive: value.Recursive,
Pattern: cloneImportDefaultStrings(value.Pattern), Pattern: value.Pattern,
} }
} }
func cloneImportDefaultBool(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneImportDefaultStrings(values []string) []string {
if values == nil {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil { if value == nil {
return nil return nil
@@ -127,16 +99,6 @@ func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersi
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted, Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
} }
} }
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
if value == nil {
return nil
}
return &engine.SourceVersionCheck{
Path: toEnginePath(value.Path),
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
}
func fromEngineOrigin(value engine.Origin) Origin { func fromEngineOrigin(value engine.Origin) Origin {
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
if value.Inheritance != nil { if value.Inheritance != nil {
@@ -150,19 +112,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
} }
return result return result
} }
func toEngineOrigin(value Origin) engine.Origin {
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
if value.Inheritance != nil {
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
for index, step := range value.Inheritance {
result.Inheritance[index] = engine.InheritanceStep{
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
Locations: toEngineLocations(step.Locations),
}
}
}
return result
}
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic { func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)} return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
} }

90
discovery.go Normal file
View File

@@ -0,0 +1,90 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// Search is an immutable ordered description of possible host-file inputs.
// It is consumed only when passed to SearchConfig and used by a load.
type Search struct{ value engine.Search }
// FilenameGroup selects filenames in order for a directory search.
// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames.
type FilenameGroup struct{ value engine.FilenameGroup }
// SearchOptions controls whether a search must select a file.
type SearchOptions struct {
// Required reports a missing-source error if the complete search is exhausted.
Required bool
}
// DirectorySearchOptions selects the filename groups used by SearchDirectory.
type DirectorySearchOptions struct {
// Filenames concatenates groups in order. A nil slice uses Named then Generic;
// a non-nil empty slice selects no filenames.
Filenames []FilenameGroup
}
// NamedFilenames selects <application>.yml followed by <application>.yaml.
func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} }
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} }
// CustomFilenames selects literal filename components in the supplied order.
// Names cannot be empty or contain a directory component.
func CustomFilenames(names ...string) FilenameGroup {
return FilenameGroup{value: engine.CustomFilenames(names...)}
}
// SearchDirectory describes a search within directory. Omitted options or a nil
// Filenames slice uses Named then Generic groups. Explicitly empty Filenames
// selects no names and does not resolve the directory base.
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
options := make([]engine.DirectorySearchOptions, len(supplied))
for index, option := range supplied {
if option.Filenames == nil {
continue
}
options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames))
for groupIndex, group := range option.Filenames {
options[index].Filenames[groupIndex] = group.value
}
}
return Search{value: engine.SearchDirectory(directory, options...)}
}
// Paths describes literal host-file candidates in the supplied order.
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
// Searches concatenates descriptions in order and snapshots their entries.
func Searches(searches ...Search) Search {
values := make([]engine.Search, len(searches))
for index, search := range searches {
values[index] = search.value
}
return Search{value: engine.Searches(values...)}
}
// UnixSystemDirectories describes the conventional Unix system directories.
// Loading on an unsupported platform fails if this preset is reached.
func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} }
// UserConfigDirectory describes searches rooted at the Go user-config directory.
func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} }
// HomeDirectory describes searches rooted at the Go home directory.
func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} }
// WorkingDirectory describes application-named files in the current directory.
func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} }
// SearchConfig creates a source that selects the first matching host file when
// consumed by Load or LoadLayered. Without Required, exhausting the search uses
// the absent-input lifecycle. Description and option errors are reported while
// loading.
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
options := make([]engine.SearchOptions, len(supplied))
for index, option := range supplied {
options[index] = engine.SearchOptions{Required: option.Required}
}
return Source{value: engine.SearchConfig(application, search.value, options...)}
}

View File

@@ -0,0 +1,68 @@
# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading
## Status
Accepted.
## Date
2026-10-24
## Context
Applications often need a small ordered convention for choosing a main
configuration file. Keeping that choice entirely in each application repeats
path selection and file-loading plumbing. Moving the convention into ConfigKit
without an explicit declaration would make ordinary loads search paths that a
consumer did not choose, obscuring both precedence and filesystem access.
ConfigKit already owns synchronous source acquisition, parsing, composition,
decoding, validation, and inspection. A selected host file can therefore enter
that lifecycle as the existing file source, preserving its source identity,
resource context, and diagnostics. Optional exhaustion can use the same absent
main lifecycle as an explicitly absent source.
## Decision
Consumers choose and order the main-file conventions they want to use. They may
describe literal paths, directory filename groups, and supported directory
presets, and may mark the complete search as required. Search descriptions are
explicit inputs to loading; no preset or convention is activated implicitly.
During synchronous loading, ConfigKit resolves the description to one real host
file or to absence before parsing. The selected file follows the existing source
acquisition and processing rules. A selected-file access, parse, version,
composition, decode, or validation failure is terminal and does not resume the
search. Optional exhaustion shares the absent-main lifecycle; required
exhaustion reports a missing-source diagnostic. No candidate-attempt history is
added to public metadata.
This keeps convention choice with applications while moving repeated selection
and loading plumbing into the library's established lifecycle. It preserves
explicit control, ordinary source behavior, and complete-on-return ownership.
## Alternatives Considered
- **Keep all search and fallback loading in each application.** This preserves
consumer ownership but repeats the file-selection and load coordination that
ConfigKit already owns, and risks divergent handling of selected-file errors.
- **Search conventional locations implicitly during every load.** This reduces
setup, but activates filesystem access and precedence the consumer did not
select and makes ordinary source behavior less predictable.
- **Return candidate paths for the application to load one by one.** This keeps
selection outside the load lifecycle and encourages repeated complete loads,
potentially rerunning defaults, callbacks, and validation during fallback.
## Consequences
The public search description must be immutable and root-owned, with private
engine conversion and selection. The contract documents candidate ordering,
selection, exhaustion, context, and provenance; the API declaration documents
the public symbols. Dynamic bases and file availability are observed when each
load consumes the description. Applications remain responsible for selecting
conventions and for their schema and domain requirements.
The [configuration contract](../config.md) owns implemented behavior, and the
[component overview](../internal/overview.md) describes implementation ownership.
The completed implementation plan and remediation record are preserved in Git
history at `0369111` under `docs/roadmap/`.

View File

@@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract | | Constructor | Current contract |
| --- | --- | | --- | --- |
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. | | `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
| `SearchConfig(application, search, options...)` | Creates an explicit, reusable search source. Loading selects one host file or follows the absent-input lifecycle when an optional search is exhausted. |
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. | | `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. | | `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. | | `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
@@ -28,6 +29,79 @@ Keys are sorted for stable provenance, integer decimal spelling stays exact, and
cycles or unsupported types are rejected without exposing values. Mapping input cycles or unsupported types are rejected without exposing values. Mapping input
uses the same directive and version rules as YAML, not CLI override restrictions. uses the same directive and version rules as YAML, not CLI override restrictions.
### Configuration discovery
Discovery is opt-in. Construct `Search` from literal `Paths`, directory searches,
or the supported directory presets, then pass it to `SearchConfig`. Search
descriptions are immutable and reusable; each load resolves dynamic directories
and checks current candidate files again. `Searches` is ordered concatenation.
It preserves the supplied description order and does not deduplicate candidates.
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
`main.yaml`. `SearchDirectory` defaults to `[Named, Generic]` when options are
omitted or `Filenames` is nil. A non-nil empty filename list contributes no
candidates and does not resolve its directory. `CustomFilenames` supplies
literal single filename components; it does not expand application-name
placeholders. An application slug is required only when a selected directory or
filename group uses it. Literal custom paths are not expanded for `~`, variables,
or wildcards; relative paths are resolved from the working directory at load time.
The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group
contribute no candidates. An explicitly supplied empty path or filename is
invalid. Slugs and custom filenames must be usable single host filename
components; ConfigKit preserves their case and spelling.
Search expansion is ordered concatenation. For each directory search, ConfigKit
tries its filename groups and names before advancing to the next directory.
When multiple directories use the same filename list, candidates follow the
directory-first Cartesian product: every filename in one directory is tried
before the next directory. Explicit file candidates stay at their declared
positions among directory searches.
The presets expand to these ordered directories and filename groups:
| Preset | Directory, in order | Filename groups |
| --- | --- | --- |
| `UnixSystemDirectories` | `/usr/local/etc/<application>`, `/etc/<application>` | Named, then Generic |
| `UnixSystemDirectories` | `/etc` | Named only |
| `UserConfigDirectory` | Go user-config directory / `<application>` | Named, then Generic |
| `UserConfigDirectory` | Go user-config directory | Named only |
| `HomeDirectory` | Go home directory / `.<application>` | Named, then Generic |
| `HomeDirectory` | Go home directory | Named only |
| `WorkingDirectory` | Current working directory | Named only |
The Unix system preset is supported on aix, android, darwin, dragonfly, freebsd,
hurd, illumos, ios, linux, netbsd, openbsd, and solaris; reaching it on another
platform is an option error. Other presets use Go's user-config, home, and
working-directory helpers. A reached
directory-helper error terminates selection. Bases for later searches are not
resolved after an earlier candidate is selected. Missing candidates continue;
the first existing candidate is selected and then follows ordinary file loading
rules. Errors while acquiring or processing a selected file are terminal and do
not resume the search.
Searches are optional by default. Exhaustion uses the same absent-main lifecycle
as `NoSource()`: there is no main document, source record, source ID, or explicit
presence contribution. Imports may be enabled but there are no main declarations
to traverse. Defaults, version checks, overlays, CLI overrides, resolution, and
validation retain their normal lifecycle. `SearchOptions{Required: true}` makes
exhaustion an error; it does not change candidate or selected-file error handling.
For an ordinary active overlay after optional exhaustion, provide an explicit
filesystem context. A host context with only `Root` is sufficient; ConfigKit
loads the overlay at its normal root-relative location and does not infer a root
from attempted search directories. The same root-only context can accompany a
search that selects a file: the selected file determines its base and must be
inside `Root`. A selected host file rejects an explicit `BaseDir` or native
filesystem context. Invalid context fails without resuming selection. Layered
overlays keep their independent `OverlayFilesystem` requirement.
When a search selects a file, inspection reports that actual file with ordinary
source IDs, paths, placement, metadata origins, and resource context. Missing
candidates create no records. `SearchConfig` can be used as the main source of
ordinary or layered loading, including an underlay input, and does not add a new
precedence tier.
Each source is limited to 8 MiB. A load using imports or an active overlay is limited Each source is limited to 8 MiB. A load using imports or an active overlay is limited
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
@@ -78,9 +152,14 @@ non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
source-root collection declarations whether or not imports are enabled; an source-root collection declarations whether or not imports are enabled; an
`imports` declaration still requires imports to be enabled. `NoSource` rejects `imports` declaration still requires imports to be enabled. `NoSource` may enable
imports, active overlays, and filesystem context as invalid combinations but imports, but it has no main declarations to traverse. It accepts an explicit
accepts overrides and remains an absent source rather than an empty document. filesystem context: a host context requires an absolute existing `Root` and may
omit `BaseDir` to use that root as its internal import base; a native context
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
when imports are disabled. An active ordinary overlay requires this explicit
context. `NoSource` accepts overrides and remains absent rather than contributing
an empty document.
Every source, options, schema, version, defaults, decode, resolution, or validation Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
@@ -149,7 +228,8 @@ An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly
independent of the main source's namespace. Its name and root-relative lookup independent of the main source's namespace. Its name and root-relative lookup
follow the selected-overlay rules below; inactive overlays ignore the context. follow the selected-overlay rules below; inactive overlays ignore the context.
The same path in a different filesystem namespace is not a repeated file. The same path in a different filesystem namespace is not a repeated file.
Ordinary `Load` continues to infer its selected-overlay context from its source. Ordinary `Load` infers overlay context from sources that provide one; with
`NoSource`, it requires the explicit `Options.Filesystem` context.
Source IDs follow acquisition order—main and its depth-first imports, then each Source IDs follow acquisition order—main and its depth-first imports, then each
underlay tier's inputs and depth-first imports, then overlay and CLI—even though underlay tier's inputs and depth-first imports, then overlay and CLI—even though
@@ -969,6 +1049,12 @@ Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, wi
no extension fallback, case normalization, inheritance between overlay files, no extension fallback, case normalization, inheritance between overlay files,
or overlay stack. A selected missing overlay is an error. or overlay stack. A selected missing overlay is an error.
With `NoSource`, the main input stays absent and the explicit filesystem context
supplies the overlay root. The overlay is recorded as the first acquired source
when no other source exists. Host contexts may use the root-only form described
under [single-source loading](#single-source-loading); native contexts retain
their complete namespace, base, and root requirements.
The overlay is a separately policy-validated YAML mapping. It is loaded after the The overlay is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults base import graph is composed and its schema version is checked, but before defaults
and typed decoding. Overlay bytes, nodes, and source records count toward the same and typed decoding. Overlay bytes, nodes, and source records count toward the same

View File

@@ -6,6 +6,30 @@ applications. Consumers still own their configuration structs, defaults, semanti
validation, schema-version policy, CLI parsing, runtime matching and conversion, validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization. resource checks, remote acquisition, and service initialization.
## Choosing a loading entry point
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
[`SearchConfig`](../../discovery.go) is also a source, so its explicit
discovery result can be passed to `Load` or used as the main input to
[`LoadLayered`](../../layered.go).
- Use `LoadLayered` when inputs must be composed in underlay groups before the
main source. Each [`Input`](../../layered.go) owns its source, filesystem,
destination, and import choice. The overlay filesystem context is selected
independently in [`LayeredOptions`](../../layered.go).
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
[`LayeredOptions`](../../layered.go): the application definition (including
version policy), import defaults, and explicit overrides. These do not turn
on imports or discovery by themselves. See the
[configuration contract](../config.md#layered-loading) and
[discovery contract](../config.md#configuration-discovery) for exact rules.
The root [README example](../../README.md) shows the small `Load` path.
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
file and layered cases. For import-focused adoption, see the
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
discovery composed with sequences, see the
[sequence catalog](../../examples/consumers/sequence_catalog.go).
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library: sanitized shapes using only ConfigKit and the Go standard library:
@@ -55,6 +79,21 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts. execution profile concepts.
## Explicit main-file discovery
Applications choose the search conventions and their precedence, then pass the
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
For example, the maintained sequence catalog puts a caller-selected file before
a custom directory fallback while keeping its underlay and overlay choices
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
the [working-directory executable example](../../examples/consumers/discovery_test.go).
Together, the root-owned [discovery API](../../discovery.go) and [configuration
discovery contract](../config.md#configuration-discovery) define ordered
concatenation, directory-first Cartesian expansion, optional exhaustion, and
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records why discovery is explicitly selected.
## Import defaults and selection inspection ## Import defaults and selection inspection
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one `Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one

View File

@@ -43,7 +43,7 @@ public behavior.
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below | | Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
| Documentation changes | The owning document | | Documentation changes | The owning document |
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure | | Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below | | Architecture decisions or future work | [Future features](roadmap/future.md), [remediation roadmap](roadmap/roadmap.md), and relevant ADRs listed below |
| Code and test changes | The owning contract and tests from the map below | | Code and test changes | The owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) | | Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release procedure](release.md) | | Release preparation | [Release procedure](release.md) |
@@ -63,6 +63,7 @@ Accepted decisions:
- [Separate import selection controls](adr/0016-independent-import-controls.md) - [Separate import selection controls](adr/0016-independent-import-controls.md)
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md) - [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md) - [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
For cross-cutting changes, follow every applicable row. Completed implementation For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -112,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
``` ```
Remaining optimization candidates belong to Remaining optimization candidates belong to
[future performance experiments](roadmap/future.md#3-performance-experiments). [future performance experiments](roadmap/future.md#5-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under resolved review findings are preserved in Git history at `1c2610c` under
@@ -167,7 +168,46 @@ The subsequent import-integration audit and completed remediation record are
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
`git show 6903c9e:docs/roadmap/remediation.md`. `git show 6903c9e:docs/roadmap/remediation.md`.
The completed sequence-identity/collection-policy roadmap and implementation
plan are preserved in Git history at `d0a605a`:
```sh
git show d0a605a:docs/roadmap/sequences.md
git show d0a605a:docs/roadmap/implementation.md
```
Current behavior belongs to the configuration contract's
[strict composition](config.md#strict-document-composition),
[sibling inheritance](config.md#sibling-inheritance), and
[CLI overrides](config.md#explicit-cli-overrides) sections.
The completed remediation record, including the algorithm-audit findings, is
preserved at `ea46001:docs/roadmap/remediation.md`.
The completed configuration-discovery roadmap, implementation plan, and
remediation record are preserved in Git history at `0369111`, for example
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
[configuration discovery](config.md#configuration-discovery); the public
[discovery declarations](../discovery.go) own the Go API.
The completed consolidation proposal, implementation plan, and resolved audit
findings are preserved in Git history at `34cbc0d` under `docs/roadmap/`. For
example:
```sh
git show 34cbc0d:docs/roadmap/implementation.md
git show 34cbc0d:docs/roadmap/remediation.md
```
Potential enhancements are tracked in [future features](roadmap/future.md). Potential enhancements are tracked in [future features](roadmap/future.md).
The completed audit remediation's scope is recorded in the
[remediation roadmap](roadmap/roadmap.md). The consolidated audit, including
findings, evidence, and settled scope decisions, is preserved at
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
```sh
git show 453da68:docs/roadmap/audit.md
```
Install the Go version required by `go.mod` or a compatible newer toolchain. Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline Acquire declared dependencies explicitly before entering an offline
@@ -215,16 +255,17 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command | | Task | Behavioral test owners | Focused command |
| --- | --- | --- | | --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` | | Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` | | Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` | | Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` | | Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` | | Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` | | Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` | | Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` | | Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` | | Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |

View File

@@ -7,7 +7,11 @@ This note records implementation flow and private identities for maintainers.
## Discovery and acquisition ## Discovery and acquisition
Each source's root directive is parsed into presence-aware declarations and Each source's root directive is parsed into presence-aware declarations and
resolved against load-wide defaults. Candidate selection preserves a normalized resolved against load-wide defaults. `acquisition.go` owns shared acquisition
policy, load-wide accounting, and tier-scoped repetition/namespace state;
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
and import admission. Candidate
selection in `import_selection.go` preserves a normalized
namespace path separately from canonical access identity: overlapping roots namespace path separately from canonical access identity: overlapping roots
coalesce the same candidate spelling in that namespace, while aliases reached coalesce the same candidate spelling in that namespace, while aliases reached
through distinct spellings retain separate inclusion routes for admission. through distinct spellings retain separate inclusion routes for admission.
@@ -20,10 +24,34 @@ Directory traversal tracks canonical directories on the active traversal path.
It adds an identity when descending and removes it when the frame is popped, so It adds an identity when descending and removes it when the frame is popped, so
only a back-edge to an active directory is a traversal cycle; a later alias is only a back-edge to an active directory is a traversal cycle; a later alias is
not mistaken for one. This is separate from import dependency-cycle detection. not mistaken for one. This is separate from import dependency-cycle detection.
For host sources, discovery uses rooted `Lstat` and canonical containment. Native For host sources, discovery uses rooted `Lstat` and canonical containment.
sources use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows `filesystem_context.go` owns resolved access facts and live access setup; the
the capabilities and behavior of the provided `fs.FS`, without claiming a acquisition operation closes its live handles before composition. Native sources
general sandbox guarantee. use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
capabilities and behavior of the provided `fs.FS`, without claiming a general
sandbox guarantee. `source_context.go` derives passive resource contexts from
resolved sources for later caller-owned reads; those records do not retain live
acquisition handles.
Host and native link bindings share one iterative link-frame evaluator. It
tracks active links for cycle detection and reuses successfully completed link
targets while resolving the remaining path components; each binding retains its
own namespace and target-containment rules. Directory reads distinguish an
initial-open failure from failures after opening: only an initial missing
directory can be treated as optional, while enumeration and close failures
remain selection errors, including errors from the standard `fs.ReadDir`
fallback.
Glob selection keeps traversal coordination in `selectGlobImportCandidates`:
it advances literal and patterned segments, reads directories, unions and sorts
candidates, validates final targets, then applies exclusions and requiredness.
For each patterned directory entry, `prepareGlobEntry` handles matching and
hidden filtering, containment, target and link inspection, intermediate-directory
eligibility, host/native access-path selection, and candidate identity and label.
It returns a candidate, an ordinary skip, or a diagnostic without mutating the
selector's candidate set or traversal state. Directory entry preparation remains
separate because its matching, absence, recursion, and traversal-state rules
differ.
Acquisition remains authored: declarations are visited in source order and each Acquisition remains authored: declarations are visited in source order and each
selected candidate set in namespace-path lexical order. This preserves source selected candidate set in namespace-path lexical order. This preserves source
@@ -64,6 +92,12 @@ tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
returns a closed cycle rotated to its smallest key; blocked dependents are not returns a closed cycle rotated to its smallest key; blocked dependents are not
reported as cycle members. reported as cycle members.
Import edge preparation builds an importer-to-target adjacency index once for
an acquisition's outer and item-local scheduling, and once for grouped
scheduling. Each scope gathers edges from its participating identities and
retains only edges with both endpoints in its local slots. The retained
inclusion graph still owns cycle diagnostics and source relationships.
Import composition schedules a main tier or one underlay group as a single Import composition schedules a main tier or one underlay group as a single
strict-scope graph. Applicable collection declarations are gathered for the strict-scope graph. Applicable collection declarations are gathered for the
scope before peer composition, including identity inherited from lower tiers. scope before peer composition, including identity inherited from lower tiers.
@@ -78,10 +112,14 @@ mapping order in its output.
These scopes do not flatten into one global graph, and independent precedence These scopes do not flatten into one global graph, and independent precedence
tiers remain separate. tiers remain separate.
The main owners are [`import_selection.go`](../../internal/engine/import_selection.go), The owners are [`acquisition.go`](../../internal/engine/acquisition.go),
[`import_selection.go`](../../internal/engine/import_selection.go),
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
[`directory_walk.go`](../../internal/engine/directory_walk.go), [`directory_walk.go`](../../internal/engine/directory_walk.go),
[`import_graph.go`](../../internal/engine/import_graph.go), [`import_graph.go`](../../internal/engine/import_graph.go),
[`import_projection.go`](../../internal/engine/import_projection.go),
[`dependency_order.go`](../../internal/engine/dependency_order.go), and [`dependency_order.go`](../../internal/engine/dependency_order.go), and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go). [`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
Their focused behavior is covered by import-selection, scheduler, and scheduling Their focused behavior is covered by import-selection, acquisition, scheduler,
integration tests under `internal/engine`. and scheduling integration tests under `internal/engine`; see the
[development test map](../development.md#task-to-test-map).

View File

@@ -7,33 +7,41 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners: Implemented component owners:
- `internal/engine` owns host and native-filesystem source acquisition, - `internal/engine` owns host and native-filesystem source acquisition and
grouped strict composition, recursive precedence assembly, per-input mapping explicitly selected configuration discovery. A search resolves once during
and sequence-item placement, YAML collection declarations and operations for shared acquisition; the selected concrete file or absent-input state then
identified sequences, and application identity registrations for strict follows the existing composition, metadata, and resource-context lifecycle.
It also owns grouped strict composition, recursive precedence assembly,
per-input mapping and sequence-item placement, YAML collection declarations
and operations for identified sequences, and application identity registrations for strict
composition, inheritance, and final validation. It applies the selected composition, inheritance, and final validation. It applies the selected
collection operation at precedence boundaries and owns dependency-ready, collection operation at precedence boundaries and owns dependency-ready,
destination-local inheritance before typed decoding. CLI assignment follows destination-local inheritance before typed decoding. CLI assignment follows
inheritance and decoding, before application resolution. The engine also owns inheritance and decoding, before application resolution. The engine also owns
shared source-version checking with sanitized declaration evidence. shared source-version checking with sanitized declaration evidence.
Explicit import discovery supports declared paths, directory searches, and Explicit import discovery supports declared paths, directory searches, and
globs; load-wide defaults and source-local empty-selection evidence cross the globs; root-owned defaults are converted to the engine's single defaults input
root facade without changing acquisition order. Import and inheritance and snapshotted during load preflight before acquisition, while source-local
resolution share dependency-ready scheduling within their composition scopes. empty-selection evidence crosses the facade without changing acquisition
order. The root constructs placement directly on engine options and inputs
using explicit enum conversion; public types and fields remain root-owned.
Import and inheritance resolution share dependency-ready scheduling within
their composition scopes.
It retains each source's effective placement alongside physical source It retains each source's effective placement alongside physical source
identity and owns schema identity and owns schema
inspection, lifecycle coordination, callback auditing, inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and provenance/history, passive source-context retention, secret resolution and
bootstrap, safe inspection rendering, and effective-value comparison over bootstrap, operation-local secret failure-location attribution, safe
inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package. frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records, - Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
or records. Callback wrappers pass only live engine context during a callback; or records. Callback wrappers pass only live engine context during a callback;
`Result.Config` is detached and its private inspection remains frozen `Result.Config` is detached and its private inspection remains frozen
independently. independently.
- Root conversion files explicitly cross the path, record, callback, secret, and - Root conversion files explicitly cross the path, record, callback, secret,
error boundaries. There are no public aliases, anonymous engine embeddings, or search-description, and error boundaries. There are no public aliases,
public engine bridge methods. anonymous engine embeddings, or public engine bridge methods.
- Root `Diff` and `Difference` provide the public comparison boundary and - Root `Diff` and `Difference` provide the public comparison boundary and
convert detached engine records to root-owned paths and snapshots; consumer convert detached engine records to root-owned paths and snapshots; consumer
examples own presentation of the safe changed regions. examples own presentation of the safe changed regions.
@@ -53,7 +61,9 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types. internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics, including [input destinations](../config.md#input-destinations), semantics, including
[configuration discovery](../config.md#configuration-discovery),
[input destinations](../config.md#input-destinations),
[shared source-version checks](../config.md#shared-source-version-checks), and [shared source-version checks](../config.md#shared-source-version-checks), and
[effective configuration diffs](../config.md#effective-configuration-diffs). [effective configuration diffs](../config.md#effective-configuration-diffs).
The [strict composition contract](../config.md#strict-document-composition) The [strict composition contract](../config.md#strict-document-composition)
@@ -78,5 +88,73 @@ records the shared dependency-ready ordering decision; [ADR
0016](../adr/0016-independent-import-controls.md) records the independent 0016](../adr/0016-independent-import-controls.md) records the independent
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection [ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
operations and CLI ordering. The [import internals](imports.md) document candidate and operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
namespace identities, discovery and scheduling, and the bounded algorithms. records explicit main-file discovery. The [import internals](imports.md)
document candidate and namespace identities, discovery and scheduling, and the
bounded algorithms.
## Implementation flow
The root facade converts caller-owned values into engine-owned requests. Its
`ImportDefaults` conversion maps fields without taking ownership; engine load
preflight snapshots the defaults once before source acquisition or callbacks.
Ordinary and grouped loading then carry that read-only value through their
normalized `loadRequest` and acquisition policy, while declaration resolution
copies values needed by its independently owned effective policy. Placement is
constructed directly on the engine's options and input records after explicit
root-to-engine enum conversion. See
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
version, default-import, and override policy stays distinct from each input's
source, filesystem, destination, and import settings.
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
input is acquired under that policy; grouped requests retain acquired underlay
scopes until their tier is composed in
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
share one assembly handoff that applies collection declarations and projects
only that tier's source/version evidence; the caller keeps main-version
protection and its projection explicit. Live
filesystem access handles are confined to acquisition and closed there. Resolved
filesystem facts used to authorize access are separate from passively retained
resource contexts. Non-import validation retains those facts on `assembledInput`
for passive-context construction and ordinary-overlay handling: see
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
[`source_context.go`](../../internal/engine/source_context.go).
Import responsibilities are split across candidate selection and path
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
source reading and traversal admission in
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
and projection of acquired evidence into final source coordinates in
[`import_projection.go`](../../internal/engine/import_projection.go). The
[import internals](imports.md) page describes the boundaries and identities.
After acquisition handles are closed, grouped composition and inheritance run on
the retained ordered documents. `inheritance.go` coordinates traversal and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
dependency-ready templates;
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
prepares child and parent inputs,
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
returns the combined value with its origin, policy, sequence, and diagnostic
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
accounts for and finalizes the result. Projection and schema work have separate
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
descriptor caching and type policy, and
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
those descriptors for typed decoding.
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
covered by `imports_test.go`, `load_request_test.go`, and focused source,
discovery, and destination tests; inheritance by the inheritance test files;
reflection behavior by `schema_test.go`, `projection_test.go`, and
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
copying, using the shared descriptors and per-operation traversal helpers.

View File

@@ -144,8 +144,12 @@ remains in the roadmap until implemented.
ConfigKit owns configuration input processing and the coordination of decoding, ConfigKit owns configuration input processing and the coordination of decoding,
resolution, validation, and inspection. Applications own their configuration resolution, validation, and inspection. Applications own their configuration
types, field meanings, defaults, normalization, semantic validation, and schema types, field meanings, defaults, normalization, semantic validation, and schema
version policy. Applications also own main-file search conventions, CLI wiring, version policy. Applications choose and order their main-file search conventions;
remote acquisition, runtime initialization, and resource checks. ConfigKit provides explicitly selected search descriptions that resolve during
loading. Applications also own CLI wiring, remote acquisition, runtime
initialization, and resource checks. See the [configuration discovery
contract](../config.md#configuration-discovery) and [ADR
0019](../adr/0019-explicit-configuration-discovery.md).
The engine applies each input's mapping destination within the existing source The engine applies each input's mapping destination within the existing source
assembly, applies immediate-parent-relative YAML destinations to imports, and assembly, applies immediate-parent-relative YAML destinations to imports, and
@@ -157,11 +161,13 @@ placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
the import extension. the import extension.
Library code must not depend on consuming applications or initialize their Library code must not depend on consuming applications or initialize their
runtime resources. It must not perform implicit network access, discover runtime resources. It must not perform implicit network access, implicitly
application-specific paths, or change process-global state during ordinary `Load`. discover application-specific paths, or change process-global state during
ordinary `Load`.
Import discovery is available only through declarations in selected sources: Import discovery is available only through declarations in selected sources:
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
ConfigKit does not search conventional application paths or discover a main ConfigKit does not search conventional application paths unless the application
explicitly supplies a discovery description, and it never discovers a main
configuration file implicitly. Imports and sibling inheritance share configuration file implicitly. Imports and sibling inheritance share
dependency-ready scheduling within their existing scopes; acquisition order and dependency-ready scheduling within their existing scopes; acquisition order and
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports) precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)

View File

@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version "$releasekit_bin" version
``` ```
The module-selected executable is ReleaseKit v0.4.1. Use the built executable The module-selected executable is ReleaseKit v0.4.2. Use the built executable
for publication to preserve its exit status and publication-state diagnostics. for publication to preserve its exit status and publication-state diagnostics.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands `go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected

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

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

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

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

70
docs/releases/v0.10.2.md Normal file
View File

@@ -0,0 +1,70 @@
# ConfigKit v0.10.2
This patch release fixes edge-case loading failures, resource cleanup, and limit
accounting, and simplifies ConfigKit's internal loading machinery. Public APIs
and documented configuration semantics remain unchanged from v0.10.1.
## Fixes
- Terminate pointer-only recursive type traversal safely during decoding and
schema-path resolution, preserving nullable values and supported recursive
structures. Roll back failed dynamic descriptor construction so subsequent
operations do not reuse incomplete state or panic.
- Accept finite, non-cyclic slice views that share backing storage while
retaining cycle detection and detached output.
- Deactivate resolution, validation, and version-check contexts when callbacks
panic, and close library-owned import roots during panic unwinding. The
original application panic continues to propagate.
- Report directory enumeration and close failures instead of treating them as
optional absence, including failures reported as not-exist after a successful
open. Genuinely missing optional directories retain their existing behavior.
- Reuse completed symlink dependencies during host and native import discovery,
avoiding exponential repeated expansion of finite link graphs while retaining
cycle detection and containment checks.
- Check existing projection budgets before bulk container allocation and account
for the complete retained inspection-history graph, including replay and
separately copied inheritance evidence.
- Apply the documented append/upsert sequence requirements to non-sequence
contributions at retained collection-policy destinations.
## Internal improvements
- Index collection-policy retirement and prepare sparse incoming policy changes
instead of repeatedly scanning or copying unrelated retained policy.
- Reuse successful dynamic-type eligibility checks within an operation and index
tier-history lookups, compound identity restoration, and secret-failure source
attribution to avoid repeated scans.
- Remove unused snapshots, evidence, helpers, fields, and test-only production
paths. Clarify acquisition handoffs and separate collection preparation,
application, and finalization while sharing sequence-history construction.
- Separate patterned glob entry preparation from traversal. Consolidate engine
import defaults, snapshot them during load preflight, and construct placement
directly across the facade while keeping public types root-owned.
- Add regression coverage for the corrected failures, public historical paths,
defaults snapshots, and resource ownership. Strengthen fuzz detachment checks
and replace redundant or implementation-coupled assertions.
- Update release tooling from ReleaseKit v0.4.1 to v0.4.2.
These changes remove redundant work and improve scaling in the identified paths;
no end-to-end performance improvement has been quantified.
## Compatibility and upgrade
No consumer API or configuration-format migration is required. The
[configuration contract](../config.md) remains authoritative.
Loads that previously succeeded despite directory acquisition failures, invalid
append/upsert contributions, or incomplete retained-history accounting may now
return the documented structured errors. These corrections enforce existing
requirements and limits rather than introducing new ones.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.2
```
The consumer minimum remains Go 1.26.0. The YAML dependency and platform support
are unchanged. Publication follows the existing [release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/docs/oss/attribution.md)

View File

@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
successful-tag-push completion point remain unchanged; CI is not a gate. successful-tag-push completion point remain unchanged; CI is not a gate.
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the - Adopt release policy 1.6, distinguishing the consumer Go minimum from the
validation toolchain. Retire completed planning and measurement records into validation toolchain. Retire completed planning and measurement records into
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments). Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
## Compatibility and upgrade ## Compatibility and upgrade

111
docs/releases/v0.9.0.md Normal file
View 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)

View File

@@ -1,221 +0,0 @@
# Internal Complexity Refactoring Roadmap
Status: proposed target state. This roadmap defines a behavior-preserving
refactor; a later implementation plan will define stages and exact private
signatures and file names.
## Purpose
Make ConfigKit's import, inheritance, and projection implementation easier to
understand, review, and change while preserving its public APIs and configuration
behavior. Concentrate on functions that coordinate several responsibilities and
carry substantial shared state.
The target is clearer responsibility and ownership. Adding a few well-named
helpers, private records, or focused files can serve that target even when total
line count increases. Complexity measurements help locate candidates; they do
not establish function-size limits, score thresholds, or release gates.
The [architecture policy](../policy/architecture.md) owns package and ownership
constraints. The [configuration contract](../config.md) and exported Go comments
remain authoritative for consumer behavior.
## Current Baseline
Reviewed baseline: `7a83704f43ef9870db33661231cdff5d6ee502f7`.
The root facade delegates to a cohesive private engine, supported by the ordered
document representation and YAML adapter. Most functions are small. The
complexity audit identified these concentrated maintenance costs:
| Area | Current implementation | Required improvement |
| --- | --- | --- |
| Imports | `imports.go` spans acquisition, item-local composition, and evidence projection. Private loading variants pass long positional argument lists. Source admission and directory selection contain substantial mixed control flow. | Give import entry points explicit, cohesive inputs and separate the responsibilities of acquisition, scope assembly, and evidence projection. |
| Inheritance | `applyInheritanceScopeTemplates` coordinates payload preparation, parent combination, policy ownership, origins, history, and accounting in one executor. | Keep one readable executor with focused operations and explicit state handoffs. |
| Projection | `valueOperation.project` combines type dispatch with struct flattening, map and sequence traversal, scalar conversion, sensitivity, and accounting. | Retain clear dispatch while moving substantial value-kind handling into focused helpers. |
The implemented inventory belongs to the
[component overview](../internal/overview.md). Existing import flow and scheduling
are described in [import internals](../internal/imports.md).
## Target Architecture
The refactor stays within `internal/engine`. The root facade, `internal/document`,
and `internal/yaml` retain their existing responsibilities and dependency
direction. Focused files and ordinary private functions express the new internal
organization. Package extraction is outside this roadmap's selected design.
`executeLoadRequest` remains the visible coordinator of the shared load lifecycle.
Its phase order should remain easy to follow. Helpers expose meaningful work and
results rather than distributing that order across a generic execution framework.
Existing shared mechanisms remain shared: recursive mapping combination,
identified-sequence operations, dependency-ready scheduling, schema descriptors,
and projection/accounting state. New helper boundaries make their ownership and
use clearer without creating parallel implementations.
### Imports: Cohesive Inputs And Scope Ownership
Import loading has a clear private entry surface using named request/context
fields for related inputs. Reuse the existing load-request, acquisition-scope,
and traversal models where they fit. Consolidate forwarding variants whose only
purpose is passing another optional positional argument through the chain.
Retain convenience helpers when they express a useful, distinct operation.
Keep these state lifetimes identifiable:
- Load-wide state: accounting, shared inclusion/repetition information,
namespaces, and source-version processing.
- Acquisition requests: source and filesystem context, placement, declarations,
applicable policy, and the relevant assembly handoff.
- Composition scopes: peer contributions, item-local helpers, record ownership,
and pending declarations.
- Evidence: physical source records, effective destinations, origins, and retained
history projections.
Group acquisition, pending-item assembly, and evidence projection in focused
files. A caller should be able to identify which operation acquires resources,
which composes already acquired content, and which projects retained evidence.
Shared mutable state has a visible owner; a request record need not duplicate
state already held by that owner.
Within dense admission, directory-selection, and declaration-parsing routines,
extract coherent operations where that reduces nested, mixed responsibilities.
Preserve the existing selection algorithms and host/native filesystem behavior.
Resource ownership and closure remain explicit across success and failure paths.
The resulting flow preserves the distinction among acquisition order, strict
scope scheduling, and precedence. Completing a helper scope before placing its
item remains part of the same import flow. Ordinary and layered loading continue
to share the applicable mechanisms.
### Inheritance: One Executor With Explicit Phases
Keep the mapping and identified-sequence adapters and their shared template
executor. The executor coordinates focused operations for:
- Preparing child payload, directives, and destination-local state.
- Preparing the resolved parent payload and applicable inherited policy.
- Combining values through the existing mapping and sequence mechanisms.
- Producing policy, origin, replacement, and replay evidence from that combination.
- Accounting for expansion and retained history under the existing budgets.
These are responsibility boundaries, not a required helper count or a new
scheduler. Small private result records are appropriate when related outputs
otherwise travel through long argument lists or implicit mutations.
Authored templates, resolved values, scoped policy, and inspection evidence remain
distinguishable. Immutable borrowed inputs and owned mutable results have clear
lifetimes. Nested instructions remain available for destination-local evaluation.
For identified-record inheritance, the child's own identity fields and origins
remain intact; the parent's identity fields are excluded from inherited payload
and its history, as the current contract requires.
One sequence-operation result continues to drive value construction, record
ownership, origins, and historical position projection. Helpers consume that
evidence directly. Scheduling remains dependency-ready, enclosing relationships
resolve before nested ones, and assembled sequence order remains unchanged.
### Projection: Explicit Value-Kind Handlers
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
the owner of per-operation traversal and accounting state. The central dispatch
clearly selects the appropriate handling for pointers, dynamic values, structs,
maps, sequences, and scalars.
Substantial struct, map, and sequence work lives in focused handlers. Struct
projection retains declaration order, inline flattening, collision diagnostics,
and sensitivity propagation. Map projection retains deterministic key ordering.
Scalar conversion retains the existing number, duration, text-codec, and secret
reference representations.
Depth and node accounting retain their existing meaning, including flattened
containers. Cycle tracking stays active through recursive child traversal and
is balanced on error paths as well as success. Passing state through a helper
does not introduce another full-tree projection or an unnecessary ownership copy.
Cloning and source-value construction remain distinct operations with their
existing semantics. Reuse small helpers when the semantics coincide; keep the
projection refactor centered on making the existing traversal readable.
## Behavior And Scope Preservation
The implementation preserves:
- Root-owned public types, signatures, supported import paths, and facade boundaries.
- Supported source forms, opt-ins, import controls, placement, acquisition and
composition ordering, and source/version evidence.
- Strict peer composition, precedence operations, identity agreement and lifetime,
record ownership, and destination-local inheritance.
- The load lifecycle, including typed defaults, post-decode CLI assignment,
recorded application resolution, and final validation.
- Explicit presence, provenance, history, sensitivity, resource contexts, and
detached publication/query results.
- Established diagnostic codes, semantic paths, source coordinates, ordering,
resource lifetime, and existing limit behavior.
The owning contracts define the extent of these obligations. Incidental private
call sequences, exact helper names, and allocation counts do not acquire new
guarantees through this roadmap. The work adds no consumer options, configuration
syntax, dependencies, or complexity-based gates.
## Relationship To Remediation And Other Work
[Remediation](remediation.md) owns the identified correctness and algorithmic
efficiency findings. Several overlap these files, particularly item-scope
application matching and inheritance policy localization.
Use the documented contract and completed remediation as the behavioral baseline.
Keep corrective changes reviewable under their findings, and retain their
regressions through this refactor. The later implementation plan should coordinate
overlapping work and select its starting revision rather than duplicating the
remediation plan here. These structural changes do not close a finding merely
because its function moved or became smaller.
The pending [discovery roadmap](discovery.md) remains separate feature work.
Import cleanup should leave clear private boundaries for future use without
implementing discovery behavior as part of this refactor.
## Work Needed And Validation
Implementation needs to consolidate import inputs and adapters, separate import
responsibilities, extract inheritance operations with explicit outputs, and
decompose projection handling. Remove superseded private plumbing as its callers
migrate. Review the resulting call flow for repeated traversals, policy scans,
copies, or hidden state transitions introduced by extraction.
Reuse the existing behavioral suites under the
[development guide](../development.md) and [testing policy](../policy/testing.md):
- Imports: selection, scheduling, source/version evidence, placement, helper scopes,
ordinary/layered equivalence, filesystem failures, and resource lifetime.
- Inheritance: mapping and record relationships, nested destination resolution,
cancellation and replacement, policy ownership, history, sensitivity, and
independent loads.
- Projection: schema/decoding agreement, inline fields, text codecs, nil/empty
values, cycles and budgets, secrets, and detached results.
Add focused regressions only where a consequential handoff risk lacks coverage.
Tests should continue to describe behavior as private functions and files move.
Use the existing everyday check and applicable ownership race checks during
implementation. Complexity measurements can inform review; acceptance does not
depend on numerical scores or a new benchmark campaign.
Update the component overview and import internals to describe the implemented
ownership and file responsibilities when the refactor lands. Update contributor
navigation and test-owner references where moves affect them. Public behavior
continues to be defined by its current canonical owners.
## Completion Criteria
- Import callers pass cohesive named inputs, with clearly owned load-wide and
scope-local state and fewer redundant forwarding variants.
- Import acquisition, item assembly, and evidence projection are easy to locate
and follow across focused private files.
- Inheritance orchestration exposes value, policy, evidence, and accounting work
without requiring readers to reconstruct their interaction from one large body.
- Projection dispatch and substantial value-kind handlers have clear boundaries
and share the original operation state correctly.
- Existing APIs and contracted behavior, including completed remediation, remain
covered by the relevant behavioral and ownership checks.
- Internal documentation matches the resulting implementation.

View File

@@ -1,381 +0,0 @@
# Composable Configuration Discovery Roadmap
Status: proposal with OD-1 through OD-5 and the final review clarifications
settled and integrated below.
This document describes future work, not an implemented contract. A staged
implementation plan will follow review of the complete roadmap.
## Purpose And Ownership
Make ordinary file-based loading concise for applications that want conventional
configuration locations. ConfigKit supplies useful built-in candidate lists and
an ordered selection operation. Consumers continue to own main-file search
conventions: they choose which lists apply, their order, custom candidates, and
whether a configuration file is required.
A consumer can select one preset for a simple application or compose several
presets and custom path lists. The application supplies an explicit slug where
needed to construct conventional paths. ConfigKit does not infer application
identity from the executable name or enable discovery for existing explicit
sources.
Discovery selects one source for the existing loading lifecycle. It does not
create additional configuration tiers or merge every matching file. Existing
layered loading remains available when consumers intentionally combine sources.
## Current Baseline
The [source contract](../config.md#sources) provides explicit host files, native
filesystem files, bytes, readers, mappings, and `NoSource()`. A missing explicit
file fails. The zero source is invalid. There is no conventional main-file search.
[Ordinary loading](../config.md#single-source-loading) infers a selected overlay's
filesystem context from the main source. `NoSource()` currently rejects enabled
imports, active overlays, and filesystem context. In
[layered loading](../config.md#layered-loading), an absent main input can coexist
with underlays; a selected overlay requires an explicit `OverlayFilesystem`.
These distinctions matter when an optional search finds nothing.
The [architecture policy](../policy/architecture.md#responsibility-boundaries)
assigns search conventions to consumers and currently excludes implicit main-file
discovery. The addition should clarify that consumers may explicitly select
library-provided search conventions. Consumer ownership remains unchanged.
## Agreed Direction
### Filename Groups, Directory Searches, And Search Presets
Organize discovery around three composable descriptions. These are ordered lists:
their order determines precedence.
**Filename groups** provide reusable, ordered filename templates. The built-in
groups are:
| Group | Filenames, in order |
| --- | --- |
| Named | `<appname>.yml`, `<appname>.yaml` |
| Generic | `config.yml`, `config.yaml`, `main.yml`, `main.yaml` |
`<appname>` is the application slug supplied by the consumer.
**Directory searches** pair a directory with an ordered list of filename groups.
The default selection is `[Named, Generic]`, producing the six filenames above
in that order. A directory search may select different groups or reverse their
precedence, such as `[Generic, Named]`, while retaining each group's internal
order. Consumers can also supply custom filenames, subject to the literal
single-component rules under OD-5 below.
**Search presets** bundle ordered directory searches. Consumers can select a
complete preset for the simple case or compose presets, custom directory searches,
and explicit file candidates in their desired order. Explicit file candidates
are literal paths and receive no filename expansion.
### Ordered Expansion And Concatenation
The selection model is **ordered expansion and concatenation**, followed by
first-match selection:
1. Expand the selected presets into directory searches in their supplied order.
2. For each directory search, concatenate its selected filename groups in order,
substitute the application slug where needed, and join each filename to that
directory.
3. Concatenate the resulting candidate lists, preserving the positions of custom
directory searches and explicit file candidates.
4. Try candidates in order, stopping at the first selected file or an error as
described below.
Directory priority comes before filename priority: exhaust one directory's
filenames before trying the next directory. When several directories share a
filename list, their expansion is a Cartesian product traversed directory first.
Different directories may select different lists, so ordered expansion and
concatenation describes the complete model. Set union does not express its
precedence semantics.
The eventual documentation should explain this model using the applicable
mathematical nomenclature: ordered lists, ordered concatenation, and Cartesian
product where a shared filename list applies. Retain these names in relevant
implementation comments where they help readers understand the operations. The
model describes ordering without requiring eager construction of every candidate.
### Unix System Preset
The agreed Unix system preset is:
| Directory, in search order | Filename groups, in order |
| --- | --- |
| `/usr/local/etc/<appname>` | `[Named, Generic]` |
| `/etc/<appname>` | `[Named, Generic]` |
| `/etc` | `[Named]` |
This produces 14 candidates: six in each application-specific directory, then
`/etc/<appname>.yml` and `/etc/<appname>.yaml`. For example,
`/usr/local/etc/<appname>/main.yaml` precedes
`/etc/<appname>/<appname>.yml`. Selecting only `Named` for `/etc` is an ordinary
directory-search description; the selection algorithm needs no special exception.
### Additional Presets (OD-1 — Settled)
Provide the following independently selectable presets alongside Unix system.
Within each preset, search directories in the order shown:
| Preset | Directory | Filename groups, in order |
| --- | --- | --- |
| User configuration | `<user-config-dir>/<appname>` | `[Named, Generic]` |
| User configuration | `<user-config-dir>` | `[Named]` |
| Home directory | `<home>/.<appname>` | `[Named, Generic]` |
| Home directory | `<home>` | `[Named]` |
| Working directory | Current working directory | `[Named]` |
Resolve the user-configuration base with
[`os.UserConfigDir`](https://pkg.go.dev/os#UserConfigDir), the home base with
[`os.UserHomeDir`](https://pkg.go.dev/os#UserHomeDir), and the working-directory
base with [`os.Getwd`](https://pkg.go.dev/os#Getwd). Adopt the Go helpers'
platform-specific directory and environment behavior, including their errors;
the settled OD-2 rules below govern how discovery handles resolution errors.
The goal is to support familiar conventions through idiomatic Go. This feature
does not add a separate XDG compatibility layer or XDG system preset. A selected
user-configuration base has no additional hard-coded `<home>/.config` fallback.
The Home directory preset is a separate, explicit choice.
The working-directory preset searches only that directory and uses application-named
files; it does not walk ancestor directories. The consumer chooses precedence
between all presets. Selecting one does not automatically select any others.
### Illustrative Consumer API
Names below are illustrative and are not implemented Go API:
```go
// A simple system-wide application.
source := configkit.SearchConfig("myapp", configkit.UnixSystemDirectories())
```
```go
// A consumer-selected working-directory, user, then system search.
source := configkit.SearchConfig(
"myapp",
configkit.WorkingDirectory(),
configkit.UserConfigDirectory(),
configkit.UnixSystemDirectories(),
)
```
```go
// Explicit custom candidates followed by a selected conventional fallback.
source := configkit.SearchConfig(
"myapp",
configkit.Paths("/run/myapp/config.yml", "/opt/myapp/config.yaml"),
configkit.UnixSystemDirectories(),
)
```
Use the resulting source with ordinary loading or as a layered main input. Custom
searches and file lists participate at their specified position; they do not
silently append unselected presets. Reordering presets or their directory
searches changes search priority.
### Selection And Failure Behavior
| Situation | Selected behavior |
| --- | --- |
| Candidate is absent | Continue to the next candidate. |
| A candidate file is selected | Stop searching and load that file. |
| Candidate is inaccessible, unreadable, or not an accepted file type | Fail using normal source diagnostics. |
| Selected file fails parsing, version checks, composition, decoding, or validation | Fail without trying another candidate. |
| Entire search has no match | Absence is permitted by default; follow the shared absent-main lifecycle settled under OD-3 below. |
| Entire search has no match and is required | Fail with a diagnostic explaining the unsuccessful search. |
| Caller explicitly uses `File(path)` | Retain existing exclusive-file behavior, including errors for a missing file. |
Requiredness applies to the complete search, not to each preset or directory.
Permitting an absent file does not waive application version, schema, or domain
requirements; the remaining lifecycle may still reject an incomplete configuration.
Perform discovery before parsing the chosen main file. Do not implement fallback
by repeatedly running complete loads. Defaults, application resolution, secret
lookup, and final validation retain their existing lifecycle execution rules.
Once a file has been selected, later failure does not restart discovery.
### Construction And Filesystem Behavior
Constructors describe the search without filesystem access. Resolve dynamic base
directories and access candidate files during loading, so reusable descriptions
can observe changes on subsequent loads. Do not change the process working
directory or environment.
Reuse the host-file acquisition rules for selected files, including regular-file
checks, symlink handling, resource ownership, and source limits. The feature does
not require a new filesystem traversal engine, recursive directory search, or
new file-size limits. Candidate filenames and groups are bounded by their
explicit descriptions, rather than by scanning unrelated directories.
The exact Go names, constructors, and representation of search-wide options
belong in the implementation plan. Keep the public surface root-owned and keep
the single-preset case concise.
### Unavailable Bases And Platform Applicability (OD-2 — Settled)
Resolve directory bases only as the ordered search reaches them. Once an earlier
candidate is selected, later searches do not resolve their bases. Static invalid
search arguments still fail ordinary option validation before source acquisition.
An explicitly empty filename selection skips directory resolution as specified
under OD-4; it has no candidates to locate.
If a selected directory's base cannot be resolved, stop and report the error.
A missing directory or candidate file beneath a resolved base allows the search
to continue; a directory-resolution error does not. This distinction also applies
to optional searches: permitting no match does not suppress errors encountered
while searching. Preserve the agreed errors for inaccessible or unreadable
candidates.
Treat using the Unix system preset on an unsupported platform as an explicit
configuration error when that preset is reached. Specify its supported platforms
in the implementation plan. Do not translate the Unix preset into a different
platform's convention. User, home, and working-directory presets use their native
Go directory helpers.
This keeps search priority predictable when directory resolution fails. Consumers
select the presets appropriate to their application and environment; this proposal
does not add an option to skip unavailable bases or unsupported presets.
### No Match, Imports, And Selected Overlays (OD-3 — Settled)
Use a shared absent-main lifecycle for optional no-match and explicit `NoSource()`.
Allow imports to be enabled when the main file is absent: there are no main-file
import declarations to process, and enablement does not introduce automatic
directory scanning. Other actual sources retain their normal import behavior.
In layered loads, process underlays normally and retain the existing explicit
`OverlayFilesystem` requirement for an active overlay.
For ordinary loads with an active overlay and no main file, require an explicit
filesystem context to locate the overlay. Do not infer an overlay root from the
first or last searched directory. A missing selected overlay remains an error;
absence of a main file does not silently disable a requested overlay.
For an absent main input, a host filesystem context specifying only `Root` is
sufficient to locate the overlay; no main-file `BaseDir` is needed. If discovery
selects a host file, retain the normal file-context rules: the selected file
determines its base directory, an explicit `BaseDir` is rejected, and the file
must be contained within any supplied root. This allows one root-only context
to work with both selected and absent main inputs, subject to those existing
root and containment checks. An invalid context is an error, not a reason to
resume discovery.
Defaults, CLI overrides, application resolution, and validation continue through
the normal lifecycle. Main-version checking receives an absent main input and
may reject it under the application's existing policy. Create no synthetic empty
document or source record for the absent main file.
This deliberately relaxes the current `NoSource()` restrictions on imports and
on overlays with explicit context. Both ways of obtaining an absent main input
have the same behavior. Implementation must cover this lifecycle change as well
as candidate selection.
### Empty Composition And Implicit Preset Selection (OD-4 — Settled)
Require explicit preset or search selection to activate a convention. Supplying
no presets or custom searches produces no candidates and activates no implicit
fallback. Empty custom path lists and explicitly empty filename-group selections
also contribute no candidates. An empty component does not prevent later
components from contributing candidates.
A directory search with an explicitly empty filename selection does not resolve
its directory base. Static argument validation still applies. This gives empty
selections their no-candidate meaning without introducing directory-resolution
errors for a search that cannot select a file.
If the complete search is empty, an optional search follows the absent-main
lifecycle settled under OD-3; a required search fails as exhausted. Empty lists
are valid, supporting consumers that construct their searches programmatically.
Distinguish omission from an explicitly empty filename selection for a directory
search. Omitting the selection uses the agreed `[Named, Generic]` default;
explicitly selecting an empty list means no filenames. The eventual API must
represent both intentions clearly. Presets retain their specified filename groups.
Selecting a single preset keeps the simple case concise. Search-wide requiredness
provides the check for consumers that must find a configuration file.
### Slugs And Custom Path Interpretation (OD-5 — Settled)
Require a nonempty single-component application slug whenever a selected search
uses it to construct a directory or filename. Reject path separators, `.` and
`..`, and forms unusable as that component on the target platform. Preserve
spelling and case; do not trim, lowercase, or impose a kebab-case naming scheme.
Validation keeps the slug from changing the intended directory structure without
imposing a separate naming style.
Searches that do not use the slug do not require one. Examples include explicit
file candidates and a custom directory searched with only Generic filenames.
Treat custom paths as literal host paths. Resolve relative paths against the
working directory at load time. Do not expand shell variables, `~`, or wildcard
syntax. Consumers can construct expanded paths themselves; User and Home presets
provide the common directory-resolution conveniences. These rules keep custom
paths consistent with explicit file loading.
Custom filenames within directory searches are literal single filename
components. They cannot be absolute paths or contain subdirectory components;
use custom directory searches or explicit file candidates for those layouts.
They receive no application-name placeholder expansion. The Named group supplies
application-name expansion, while consumers may construct custom filenames
themselves before supplying them.
### Placement, Provenance, And Existing Features
The selected file participates as the actual file source. Preserve its physical
identity, effective placement, source origin, and passive resource context.
Relative imports and relative application resources use the selected file's
normal context. Discovery does not relocate overlay or CLI coordinates.
Missing candidates are not acquired sources and do not create source IDs,
configuration history, or explicit input presence. An entirely absent search
contributes no synthetic empty configuration file. Diagnostics should distinguish
an exhausted required search from a failed candidate. Existing selected-source
inspection remains the foundation; a new public per-attempt trace is not needed
for this proposal.
Preserve collection policies, inheritance, source-version rules, CLI ordering,
final validation, and frozen inspection after selection. Search-list order does
not change precedence among underlays, the main input, an overlay, and CLI values.
An explicitly selected overlay is not another main-file fallback candidate.
## Work Needed From The Baseline
The subsequent implementation plan should cover:
- Define filename-group, directory-search, preset, and search-source representations,
slug validation, and a search-wide requiredness option. Keep construction
separate from acquisition and the simple preset selection concise. Distinguish
omitted and explicitly empty directory-search filename selections.
- Implement ordered expansion and concatenation for presets, filename groups,
custom searches, and explicit file candidates, followed by first-match selection
through existing file access. Resolve each source once per consumption before
entering its parse and composition lifecycle.
- Integrate selected and absent outcomes with ordinary and layered main inputs,
placement, imports, application version checking, and overlay contexts according
to OD-3, including root-only host contexts for absent main inputs and normal
file-context checks when a file is selected. Preserve ownership and close
acquired handles on success and failure.
- Preserve real selected-source metadata and safe diagnostics without treating
absent candidates as configuration contributions.
- Exercise preset, directory, filename-group, and filename ordering, reversed
group precedence, the Unix system preset's 14 candidates and named-only `/etc`
search, custom-list composition, both filename extensions, no-match behavior,
empty components and searches, omitted versus empty filename selections,
skipped directory resolution for empty filename selections, slug-dependent and
slug-independent searches, literal custom paths and filename components,
required searches, unavailable preset bases, errors that must stop fallback,
repeated loads, and untouched explicit `File`
behavior. Cover imports, overlays, underlays, defaults, CLI, source versions,
source contexts, and source-list/inspection evidence at their boundaries.
- Clarify the architecture policy's consumer-selected discovery boundary. Record
any warranted durable ownership decision in an ADR; keep preset filenames and
API mechanics in their canonical contract when implemented. Update consumer
examples and the configuration contract when the feature lands. Explain the
three-part discovery model and its mathematical terminology in the eventual
documentation, with simple preset selection and custom composition examples.
This roadmap does not change implementation, policy, or ADRs by itself. Coordinate
implementation with the ongoing sequence integration audit rather than assuming
its in-progress changes are part of a fixed implementation baseline.

View File

@@ -1,12 +1,20 @@
# Potential Future Features # Potential Future Work
This document lists potential enhancements for consideration in future ConfigKit This document lists potential enhancements and maintenance work for consideration
releases. Entries are not accepted contracts, implementation plans, or release in future ConfigKit releases. Entries are not accepted contracts, implementation
commitments. Adopting a candidate requires an explicit design decision and a plans, or release commitments. Adopting a candidate requires an explicit design
separate plan. Significant architectural exclusions and their rationale belong decision and a separate plan. Significant architectural exclusions and their rationale belong
in ADRs; this document does not maintain a list of rejected proposals. in ADRs; this document does not maintain a list of rejected proposals.
## 1. Recursive body merging for identified records ## 1. Separate glob entry preparation from traversal
Implemented in the [internal-boundary roadmap](internal-boundaries.md#glob-entry-preparation) (C03).
## 2. Simplify redundant facade-to-engine transport
Implemented in the [internal-boundary roadmap](internal-boundaries.md#facade-to-engine-transport) (F07).
## 3. Recursive body merging for identified records
Status: deferred. The implemented Status: deferred. The implemented
[collection policy](../config.md#strict-document-composition) uses `upsert` to [collection policy](../config.md#strict-document-composition) uses `upsert` to
@@ -20,7 +28,7 @@ presence and ordering, and how provenance, history, sensitivity, and resource
context follow partially retained records. Any change requires a new decision and context follow partially retained records. Any change requires a new decision and
implementation plan; this entry does not alter current upsert behavior. implementation plan; this entry does not alter current upsert behavior.
## 2. Required version declarations for selected sources ## 4. Required version declarations for selected sources
Status: deferred. PromptKit requested a way to require version declarations on Status: deferred. PromptKit requested a way to require version declarations on
a selected subset of source files while permitting other fragments to omit them. a selected subset of source files while permitting other fragments to omit them.
@@ -56,7 +64,7 @@ compatible version or replace final configuration validation.
behavior for absent optional inputs and preserve source-local diagnostics, behavior for absent optional inputs and preserve source-local diagnostics,
inspection evidence, and existing sensitivity handling. inspection evidence, and existing sensitivity handling.
## 3. Performance experiments ## 5. Performance experiments
Status: deferred pending evidence. The completed performance audit identified Status: deferred pending evidence. The completed performance audit identified
four internal optimization candidates for separate investigation: four internal optimization candidates for separate investigation:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
# Interface Contract Remediation Roadmap
Status: target settled, including shared history ownership; implementation has
not begun.
Planning baseline: `1f64673` (v0.10.2 implementation plus documentation changes).
This roadmap covers the four findings in the [interface audit](interfaces.md).
The audit owns reproductions, evidence, and finding numbers. This document owns
the combined target, required changes, and general dependency order. A subsequent
implementation plan should own concrete stages, private implementation choices,
validation commands, and progress.
## Purpose And Scope
Restore agreement between ConfigKit's presence, metadata, history, and diagnostic
interfaces under their existing contracts. The first three findings concern
semantic paths that share a JSON Pointer spelling; the fourth concerns a
composition claimant order that is lost during diagnostic canonicalization.
Build one shared internal history model from which metadata history and
explanation are derived. It will own the historical evidence and semantic
attribution used by both views, replacing their independent reconstruction of
that attribution. This is the selected resolution of OD-1.
The [Path declarations](../../path.go), [metadata contract](../config.md#layered-metadata),
[resolution contract](../config.md#recorded-application-resolution), and
[strict-composition contract](../config.md#strict-document-composition) remain
authoritative. This work changes the implementation of those contracts without
selecting a new public API, path syntax, precedence rule, or configuration
restriction. The existing root-owned facade and engine responsibilities remain.
## Current Baseline And Targeted End State
An explicit mapping-key path and an explicit sequence-index path can both render
as `/shape/0`. `ParsePath` deliberately leaves numeric tokens contextual, whereas
`Keys` and `Index` preserve the caller's explicit choice. The engine already has
shape-aware effective/historical path indexes and a frozen resolution-entry view,
but some queries and metadata updates still use pointer spelling alone.
History currently reconstructs definition and previous-origin records, while
explanation replays source contributions, inheritance, decoding checkpoints, CLI
assignments, and grouped resolution snapshots. Neither public representation
contains everything needed by the other. The target is shared underlying evidence
with distinct projections for each interface, preserving their existing behavior.
| Finding | Current discrepancy | Target |
| --- | --- | --- |
| 1: resolution presence | `IsExplicit` and `Default` consult string-keyed presence after schema validation, which cannot determine actual shape beneath dynamic values. | Presence decisions use the matching semantic path in the fixed resolution-entry state. |
| 2: effective explicitness | Resolution retains old field information when pointer spelling survives a container-shape change. | Input explicitness carries forward for the same semantic path; a newly introduced key or index does not inherit another path's explicitness. |
| 3: history attribution | A historical-shape check admits the query, then `History` returns the entire textual-pointer bucket; explanation selects evidence separately. | Both interfaces derive attribution from one shared history model, selecting exact definitions, previous origins, and applicable ancestor operations by semantic path. |
| 4: claimant order | Composition constructs related locations in assembly order, then canonicalization sorts them by location. | Structured and rendered composition conflicts preserve the selected claimant order. |
For example, if input contains `shape: {'0': old}` and recorded application
resolution changes `shape` to a sequence, the old key remains an input path in
the callback's entry view. The new index is effective in final metadata but was
not explicitly supplied by that input. Its history must not present the old key's
definition as its own. This distinction concerns container shape, not identity
tracking for records that move within a sequence.
## Required Changes
### 1. Use Entry-State Shape For Resolution Presence
In [resolution.go](../../internal/engine/resolution.go), align `IsExplicit` and
the explicitness decision in `Default` with the frozen, shape-aware entry metadata
already used by `InputInfo` in
[load_metadata.go](../../internal/engine/load_metadata.go).
Reuse the existing semantic-path matching machinery. A shared private lookup can
avoid divergent presence decisions without requiring every public query to have
identical error behavior. In particular:
- Preserve `IsExplicit`'s schema-path validation and its existing diagnostics.
`InputInfo` remains a query that returns false for missing or mismatched paths
without those diagnostics.
- Preserve `Default`'s helper-target validation, assignment, recording, and final
helper-value checks. Correct its presence gate; do not bypass the other checks.
- Use entry-state shape even after application code changes the working value.
Explicit false, zero, empty, and null retain their existing presence meaning.
- Keep callback lifetime and detached metadata behavior intact.
A shape mismatch beneath a schema-valid dynamic path must not become explicit
merely because its pointer spelling matches an input path. Contextually parsed
numeric tokens continue to resolve against the entry state's actual containers.
### 2. Preserve Explicitness By Semantic Path During Resolution
Update resolution metadata application and field refresh in
[load_metadata.go](../../internal/engine/load_metadata.go) to compare the relevant
entry and final shapes before reusing presence information.
The comparison must account for container kinds along the full path. A mapping
key becoming a sequence index at an ancestor can change a descendant's semantic
path even when its last segment and full pointer spelling stay unchanged.
Keep explicitness for matching surviving paths, including descendants covered by
an ancestor normalization record. New semantic paths introduced by resolution
remain non-explicit, and retain the applicable recorded resolution origin.
Removed paths leave effective metadata while remaining available to historical
queries under the existing contract. Do not clear every descendant's presence
just because its parent was normalized.
Use the existing before/after projection and entry metadata where possible.
This work does not require tracking every transient mutation inside a callback;
the existing net-change and coalesced-record model continues to apply.
### 3. Build A Shared Internal History Model
#### Evidence And Ownership
Give historical evidence one private owner in the engine. Composition,
inheritance, decoding, CLI assignment, and resolution continue to perform their
existing work and supply evidence to that owner. The model does not drive a
second configuration load or repeat application callbacks.
Organize the evidence into ordered groups with the phase boundaries required by
the existing inspection contracts:
| Evidence group | Information to preserve |
| --- | --- |
| Default baseline | Canonical typed application defaults or Go-zero values and their default attribution. |
| Source contributions | Underlay, main-tier, and selected-overlay contributions in their existing order; exact definitions, composite origins, displaced definitions, raw policy-validated values, and sequence-position projections. |
| Inheritance | Parent transfers, child contributions including equal-valued replacements, controlling directives and their consumption, identity-field preservation, and contribution origins/dependencies. |
| Typed checkpoints | Pre-CLI and post-CLI typed projections and attribution needed to reconcile retained defaults and raw contributions with decoded values. |
| CLI contributions | Explicit assignments, including equal-valued assignments, raw supplied representation, source attribution, and affected paths. |
| Resolution | Original recorded operations, reasons, dependencies, and before/after evidence, with the grouping needed to explain overlapping records as net subtree changes. |
The logical order remains baseline, source contributions, inheritance, pre-CLI
decoding reconciliation, CLI contributions, post-CLI typed reconciliation, then
recorded resolution. The two typed checkpoints belong on their respective sides
of the CLI contribution phase.
The groups describe logical inspection chronology, not a transcript of every
physical mutation. Retain the existing net-change and coalescing rules for
resolution. Preserve superseded raw input that need not decode into the final
application type; typed checkpoints do not replace that evidence.
Use a small set of ordinary private structures and existing document/path
mechanisms. Exact types, indexes, and storage layout belong in the implementation
plan. Complete evidence means enough for the existing history, explanation, and
internal attribution consumers; it does not add persistence, a public event API,
or recording of transient application effects.
#### Shared Selection And Attribution
Retain concrete semantic-path evidence when it is available. A query over the
model must distinguish exact definitions from operations affecting an ancestor
or descendant, determine the relevant previous origins, and apply sequence
contribution coordinates at their assigned positions.
The affected semantic path and the operation's reported path are distinct facts.
For example, a replacement at `/shape` can remove a mapping key and introduce a
sequence index with the same pointer spelling. Preserve enough before/after shape
evidence to answer each explicit query correctly. Merely filtering `Change.Path`
after concrete segment kinds have been discarded is insufficient.
Make this selection and attribution common to both projections, including
inheritance integration and `Previous` reconstruction. A false definition must
not survive as a previous origin after it has been excluded from the query's
history. Preserve genuine ancestor replacement/removal effects and chronology.
A path that never existed does not acquire history solely because an ancestor
changed.
Keep the contextual meaning of parsed numeric tokens across historical states
and the one-entry-per-canonical-pointer enumeration of `HistoricalPaths`.
Indexes and compact derived records may remain, but their attribution must come
from the shared evidence. Metadata queries should not require full configuration
replay or YAML rendering to obtain definition and origin records.
#### Distinct Public Projections
`Metadata.History` produces detached definition, inheritance, and resolution
records from the model. `Explain` reconstructs and safely renders values from
the same evidence, retaining its existing decoding and grouping behavior. Neither
public API should be implemented by translating the other's public result.
Preserve these intentional differences:
| Case | Metadata history | Explanation |
| --- | --- | --- |
| Several strict contributions to a mapping | Composite definition/origin information. | Each contribution's effect on the mapping. |
| Raw input differs from canonical typed values | Definition and attribution records. | The applicable decoding reconciliation transition. |
| Overlapping resolution records | Specific recorded attribution under the existing coverage rules. | One net subtree event with combined reasons and dependencies. |
| Equal-valued input or inheritance operations | Explicit definition and operation attribution remains visible. | Relevant operations remain visible even when before and after values are equal. |
Agreement means consistent semantic identity and attribution, not identical
record lists. Retain original resolution records as evidence from which net
replay groups are derived; a grouped explanation alone loses information needed
by metadata.
#### Replace Existing Attribution Machinery
The common model must replace independent historical attribution, rather than
become another representation alongside it. The implementation plan should map
the following owners to their replacements:
| Current owner | Required change |
| --- | --- |
| History-building portions of `metadataFromDecoded`, `applyOverlayMetadata`, and `appendPostDecodeCLIHistory` in [load_metadata.go](../../internal/engine/load_metadata.go) and [override_context.go](../../internal/engine/override_context.go) | Produce or project shared evidence instead of independently reconstructing pointer-keyed histories. Retain effective-field construction where it is still needed. |
| Previous-origin reconstruction and inheritance insertion in [inheritance_history.go](../../internal/engine/inheritance_history.go) | Use common semantic selection and attribution while preserving the existing inheritance projection rules. |
| Relevance and chronology orchestration in [explain.go](../../internal/engine/explain.go) | Consume the shared model; retain useful value reconstruction and redaction helpers. |
| Separate published inheritance evidence for metadata and explanation in [load_result.go](../../internal/engine/load_result.go) | Consolidate under the frozen history owner where independent copies are unnecessary; keep required output detachment. |
| Original and coalesced resolution evidence in [history.go](../../internal/engine/history.go) | Own original records in the model and derive the replay grouping without losing specific attribution or maintaining a second independently interpreted history. |
Existing source, sequence-combination, and inheritance evidence producers may
remain useful. Their algorithms need not be rewritten merely to contribute to
the model. Likewise, final effective fields, the frozen resolution-entry view,
source records, and final configuration snapshots retain their responsibilities.
Findings 1 and 2 still require explicit presence corrections.
#### Other Consumers, Lifetime, And Resource Accounting
Move historical dependency reads in
[secret classification](../../internal/engine/secret.go) and historical location
fallbacks in [override_context.go](../../internal/engine/override_context.go) to
the shared evidence. Do not retain the old history machinery solely for those
readers. Preserve existing sensitivity propagation and historical classification;
correcting attribution does not authorize narrowing redaction for paths that
share a pointer spelling.
Evidence must be available at the lifecycle stages where these internal readers
need it, including diagnostic attribution before publication. Keep the callback's
entry metadata fixed while later resolution evidence is added. Finalize the
model for published queries without introducing lazy mutation of shared state.
Private frozen evidence may be shared by the two views while their returned
records remain detached under the existing ownership contract.
Reuse retained trees and existing lookup machinery where practical. Review
retained-history accounting against the resulting ownership graph, including
typed checkpoints and inheritance/resolution evidence. The model does not require
a full document copy per event, a new quota, or a new concurrency guarantee.
Reduced duplication is a design benefit to evaluate, not a promised memory or
speed improvement.
### 4. Preserve Composition Claimant Order Through Diagnostics
Keep the order produced by `compositionConflictDiagnostic` in
[compose.go](../../internal/engine/compose.go) through accumulation, `LoadError`,
facade conversion, and `WriteDiagnostics` in
[diagnostic.go](../../internal/engine/diagnostic.go).
Prefer a narrow internal canonicalization rule based on the diagnostic semantics
already carried across these boundaries. Identify the composition diagnostic
producers governed by the claimant-order contract rather than changing all
related-location lists. Preserve canonical ordering of diagnostic records and
the existing sorting of related locations for other diagnostics.
The root renderer converts public diagnostic records back to engine records and
canonicalizes them again. An engine-only marker discarded by facade conversion
would therefore be insufficient. No public ordering option is needed for this
correction. Keep primary selection, conflict detection, acquisition order, and
dependency-ready scheduling unchanged.
## General Implementation Order
1. Prepare the concrete shared-evidence design and focused regression cases.
Map existing producers and consumers to the new owner, distinguish common
attribution from presentation rules, and identify the old paths to remove.
Select representations and indexes that preserve existing query and lifetime
needs without adding public contracts.
2. Repair entry-state presence queries and `Default` (finding 1). This is a local
correction that can proceed independently of the larger history work.
3. Repair final explicitness preservation (finding 2), retaining the shape evidence
needed by metadata updates. Coordinate this work with history construction so
the two fixes do not introduce competing definitions of path identity.
4. Build the shared model and migrate metadata history, explanation, sensitivity
dependencies, and diagnostic-location readers. Preserve phase evidence and
derive both public projections through common semantic attribution. Remove
the superseded reconstruction paths and reconcile retained-history accounting.
Verify finding 3 and its interaction with final presence metadata; repairing
pointer buckets alone does not complete the selected architecture.
5. Complete composition diagnostic ordering (finding 4) as an independent change;
it may proceed alongside the path work. Check rendering as well as the
structured error before considering it complete.
6. Review the integrated result, run applicable development checks, and update
affected implementation documentation and finding status.
These are high-level dependencies, not a requirement to combine unrelated fixes
in one commit. The implementation plan should select concrete edits and test
commands while keeping the four findings individually traceable.
## Validation And Completion
Follow the [testing policy](../policy/testing.md) and use the
[development guide](../development.md#baseline-validation) for checks. Extend
existing test owners rather than recreating full internal matrices at the facade.
The focused cases should cover:
- Both mapping-key/index mismatch directions beneath dynamic values, including
explicit paths and contextual parsed pointers. Check callback presence and
`Default` behavior, including the fixed entry view after working-value changes.
- Both container-shape transitions during recorded resolution, a descendant
affected by an ancestor shape change, and a same-shape normalization control.
Check explicitness and recorded origins through final metadata and explanation.
- Shape-changing CLI and resolution history, checking exact definitions,
`Previous`, and genuine ancestor replacement/removal. Include contextual
enumeration/query coverage and preserve existing inheritance and overlay cases;
add another case only where those integrations expose an unprotected risk.
- Shared-model projections across composite mapping origins, typed decoding and
default reconciliation, equal-valued writes, inheritance controls/transfers,
mapped sequence replacements and removed descendants, and overlapping
resolution records. Reuse existing cases where they protect those semantics;
check the intentional differences between the public projections as well as
agreement on attribution.
- Historical sensitivity/dependency propagation, diagnostic source-location
fallback, detached returned records, and complete retained-history accounting
under the new ownership graph. Use small controlled budget cases and existing
disclosure fixtures rather than memory or timing thresholds.
- A composition conflict whose acquisition and assembly orders differ, using the
audit fixture. Check both published diagnostics and rendered related-location
order, while retaining coverage for ordinary diagnostic canonicalization.
Retain existing checks for invalid schema paths, callback lifetime, detached
records, safe historical rendering, and default/null presence semantics. Test
observable behavior rather than a particular private index or number of copies.
No benchmark campaign, broader audit, or new release gate is part of this roadmap.
Completion requires all four findings to have implementation and regression
evidence, appropriate development checks, and both history interfaces deriving
semantic attribution from the shared model. Review the final producer/consumer
map to confirm superseded independent reconstruction has been removed and
internal attribution readers have migrated. Passing the four defect regressions
while retaining the old parallel attribution machinery is not completion.
The audit's previous passing suite is not validation of these future changes.
Scope here does not promise identical output from different inspection interfaces
or a universal guarantee about unrelated path-processing code.
## Decision Status
OD-1 is settled: build one shared internal history model with distinct metadata
and explanation projections. The design investigation found that both views
require evidence the other's public representation does not preserve. Shared
ownership can replace independent attribution and redundant retained evidence,
while keeping the current lifecycle and public contracts.
The narrower repair of stored history events would leave the separate replay
attribution owner in place. Promoting current explanation output to authoritative
history would lose composite-definition and specific resolution information.
The selected common underlying model addresses both concerns. This accepts a
larger internal refactor than a local shape-filtering fix.
No significant open decision remains at roadmap level. Private representation,
indexing, and helper choices belong in the implementation plan. This roadmap
records future implementation ownership; it does not revise accepted ADRs or
declare the model implemented.

202
docs/roadmap/interfaces.md Normal file
View File

@@ -0,0 +1,202 @@
# API and Interface Contract Audit
Reviewed baseline: `01b08e76a3fecc22c4414437bfeaa07b4b7a85aa`.
Status: four open findings. This is an audit record, not an implementation plan.
No production code or repository tests were changed.
The [interface remediation roadmap](interface-remediation.md) describes the
settled target, required changes, and dependencies, including a shared internal
history model for metadata history and explanation. Implementation remains pending.
This audit retains the findings and reproduction evidence.
## Scope and evidence
Reviewed the root-owned public facade and its conversions, engine loading and
callback boundaries, source/discovery and filesystem ownership, schema and typed
value handling, metadata/history, safe rendering and comparison, secrets, and
the document/YAML boundary against declaration comments, the
[configuration contract](../config.md), and applicable architectural decisions.
The root delegates to the synchronous engine lifecycle; parser-independent
documents support source assembly and inheritance, and completed results retain
detached configuration and frozen inspection state.
The findings below were reproduced through the public API with a disposable Go
program outside the repository, using labeled byte sources and `fstest.MapFS`.
The full existing suite passed with Go 1.26.10 on Linux:
```sh
GOTOOLCHAIN=local GOTELEMETRY=off GOPROXY=off go test ./... -count=1
```
The existing tests cover many ownership, lifetime, error, and zero-value cases,
but do not detect these cross-interface discrepancies. No benchmarks, external
service calls, or new test files were needed. The recommendations restore
existing contracts rather than introduce new API restrictions or guarantees.
## 1. Resolution presence queries confuse explicit keys with sequence indexes
Priority: P2. Status: open.
Relevant code:
- [`Resolution.IsExplicit`](../../internal/engine/resolution.go), lines 75–82.
- `Resolution.InputInfo` and `resolutionInput.lookup` in
[resolution.go](../../internal/engine/resolution.go) and
[load_metadata.go](../../internal/engine/load_metadata.go).
- `resolveSchemaPath` in [schema.go](../../internal/engine/schema.go).
The public [path contract](../../path.go) distinguishes explicit mapping keys
from sequence indexes. The [resolution contract](../config.md#recorded-application-resolution)
defines surviving-input presence and a fixed entry-state metadata view;
[metadata queries](../config.md#layered-metadata) preserve container shapes.
`IsExplicit` validates against the schema but then looks up presence using only
`path.String()`. In dynamic `any` state, schema validation cannot establish the
actual container shape. Consequently these distinct queries collide:
```go
key := configkit.Keys("shape", "0")
index := configkit.Keys("shape").Index(0)
```
Load `map[string]any` from `shape: [input]` with a resolver that queries both
paths, without changing the configuration. Observed results:
| Query | `Resolution.IsExplicit` | `Resolution.InputInfo` found | Final `Metadata.IsExplicit` |
| --- | --- | --- | --- |
| Explicit mapping key | true | false | false |
| Explicit sequence index | true | true | true |
The mapping-key path never existed and should not be reported as explicit.
Applications branching on presence receive inconsistent answers depending on
which public query they use, even during an otherwise unchanged load.
Potential remediation: use the retained entry-state semantic shape when checking
resolution presence, consistently with `InputInfo` and `Metadata.IsExplicit`.
Review the same string-only presence lookup in `Default` while preserving its
existing assignment and callback-lifetime rules. Add focused public coverage for
both mapping-to-index and index-to-mapping mismatches under dynamic values,
including contextually parsed pointers. This does not require changing the
documented treatment of invalid schema paths.
## 2. Resolution can transfer explicitness to a different semantic path
Priority: P2. Status: open.
Relevant code: `applyResolutionMetadata`, `applyResolutionFieldOrigins`, and
`refreshResolutionFields` in
[load_metadata.go](../../internal/engine/load_metadata.go), especially lines
371–387; final shape checking in
[`Metadata.effectiveField`](../../internal/engine/metadata.go).
The [metadata contract](../config.md#layered-metadata) distinguishes key/index
shapes and keeps derived values for absent input non-explicit. Recorded
resolution changes origins without creating input explicitness.
Reproduction:
1. Load `map[string]any` from `shape: {'0': old}`.
2. In resolution, replace `configuration["shape"]` with `[]any{"new"}` and
record normalization at `Keys("shape")`.
3. Query final metadata using the two paths from finding 1.
The old explicit mapping-key path correctly stops being effective, but the newly
created sequence index reports `Explicit: true`. That sequence index was never
supplied by input. `refreshResolutionFields` reuses the old `FieldInfo` whenever
its textual pointer survives; it updates null state without checking whether
the old and new containers give that pointer different semantic shapes. Final
lookup validates only the new shape, so it cannot recover the lost distinction.
This can mislead consumers inspecting whether a final value was explicitly
configured rather than introduced by application resolution. The converse
sequence-to-numeric-key transition has the same underlying collision.
Potential remediation: retain entry/effective shape evidence across resolution
and carry input explicitness forward only for the matching semantic path.
Preserve existing explicitness for same-shape paths and the recorded resolution
origin; do not clear every descendant's presence merely because an ancestor was
normalized. Cover both shape transitions and agreement among `Lookup`,
`IsExplicit`, and explanation. Fixing finding 1's callback query alone does not
repair this publication path.
## 3. Metadata history mixes numeric-key and sequence-index definitions
Priority: P2. Status: open.
Relevant code:
- [`Metadata.History`](../../internal/engine/metadata.go), lines 56–65.
- `metadataFromDecoded`, `applyOverlayMetadata`, and history event construction
in [load_metadata.go](../../internal/engine/load_metadata.go).
- Shape-aware replay in [`Inspection.Explain`](../../internal/engine/explain.go).
The [metadata contract](../config.md#layered-metadata) promises semantic
key/index distinctions for historical as well as effective queries. The
historical-path index retains both shapes, but `History` uses that index only to
check that the query was ever known. It then retrieves the entire history bucket
by textual pointer, without selecting the query's shape.
Reproduction: load `shape: {'0': old}` into `map[string]any`, then apply a CLI
override at `Keys("shape")` with `[]any{"new"}`. Both explicit shapes were
observed and share `/shape/0`.
`History(Keys("shape").Index(0))` incorrectly includes the original base
definition of the mapping key, and identifies that base origin as the previous
origin of the CLI operation. The old input defined a key, not an index. In the
same successful result, `Explain` for the explicit index correctly shows only
its CLI introduction. `History` for the key and index returns identical records
despite their different lifetimes.
Consequences include false source attribution and disagreement between the two
public history interfaces when consumers inspect removed or introduced paths.
Potential remediation: retain enough container-shape evidence with stored
history events, or select relevant events from existing shape-aware replay
evidence. Filter exact definitions and previous origins by the queried semantic
shape while retaining genuine ancestor replacement/removal events. Filtering
only on the current `Change.Path` is insufficient where construction already
replaced concrete segment kinds with parsed tokens. Preserve the intentional
one-pointer-per-entry `HistoricalPaths` enumeration and contextual behavior of
`ParsePath`; neither public contract needs redesign. Cover both direction changes
and compare metadata history with explanation.
## 4. Diagnostic canonicalization breaks composition claimant ordering
Priority: P3. Status: open.
Relevant code: `compositionConflictDiagnostic` in
[compose.go](../../internal/engine/compose.go) and `canonicalDiagnostics` in
[diagnostic.go](../../internal/engine/diagnostic.go), particularly lines 88–92.
The latter runs both while constructing `LoadError` and in `WriteDiagnostics`.
The [strict-composition contract](../config.md#strict-document-composition)
specifies the first claim in scoped assembly order as primary and every remaining
claim in the same order as related locations.
A native-filesystem fixture demonstrates the discrepancy:
```text
main.yml imports a.yml, b.yml, c.yml
a.yml imports z.yml
b.yml, c.yml, and z.yml each define x
```
Acquisition assigns main=source-1, a=source-2, z=source-3, b=source-4,
c=source-5. Dependency-ready assembly considers the three conflicting claims
in b, c, z order. The composer correctly chooses b as primary and constructs
related locations c, z. Generic canonicalization sorts those related locations
by acquisition ID, publishing z, c instead. The reproduced public diagnostic
has primary source-4 and related source-3, source-5.
All claimants remain present and the conflict still fails loading, so this is
lower impact than the presence/history defects. It nevertheless violates the
documented reporting order and gives consumers two different ordering rules
within one conflict report.
Potential remediation: preserve the selected semantic ordering for composition
claimant lists through accumulation and rendering, while retaining canonical
ordering of diagnostic records and the established sorting of related locations
for diagnostics without a semantic-order contract. Add one public failure-path
case whose acquisition and claimant orders differ and verify both structured
diagnostics and rendered output. No scheduler or precedence change is needed.

View File

@@ -0,0 +1,186 @@
# Import Discovery And Facade Boundary Simplification
Status: target defined; execution is tracked in the [implementation plan](implementation.md).
Baseline: `80cf23e7102fca2c5d7c7621e75f930a1ef1ecad`.
This roadmap brings together the two refactors deferred until after the audit
remediation sprint: glob entry preparation (**C03**) and facade-to-engine
transport simplification (**F07**). Their original motivation is retained in the
[glob](future.md#1-separate-glob-entry-preparation-from-traversal) and
[transport](future.md#2-simplify-redundant-facade-to-engine-transport) future-work
entries and the [consolidated audit](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md).
The goal is clearer responsibility and ownership with fewer redundant internal
representations. Both changes preserve the public API and configuration model.
They are independent work areas within one maintenance effort, not a new loading
workflow. The [implementation plan](implementation.md) owns concrete edits,
stages, validation commands, and execution progress.
## Baseline And Targeted End State
The preceding sprint repaired directory-failure classification and shared link
dependency evaluation, clarified acquisition handoffs, and removed unused
placement bridges. Those changes provide the baseline; this work does not repeat
or redesign them.
| Area | Current baseline | Target |
| --- | --- | --- |
| Glob discovery | `selectGlobImportCandidates` interleaves traversal and set operations with substantial per-entry preparation. | The selector coordinates discovery; a cohesive private operation prepares a wildcard-discovered entry. |
| Facade transport | Import defaults pass through root conversion, an exported engine transport record, an equivalent private engine record, and repeated snapshots. Placement construction also uses setter-style bridges. | Root-owned public records convert explicitly to one engine defaults representation, with straightforward engine option/input construction and identifiable ownership copies. |
The [architecture policy](../policy/architecture.md), [configuration
contract](../config.md), and public declarations remain authoritative. [ADR
0006](../adr/0006-public-facade.md) continues to govern the facade boundary. These
refactors do not require a new architectural decision or changes to its accepted
decision. The [component overview](../internal/overview.md) and [import
internals](../internal/imports.md) own implemented structure.
## Glob Entry Preparation
### Target Responsibility Split
In [glob.go](../../internal/engine/glob.go), the selector continues to own pattern
validation, component advancement, literal-segment handling, directory
enumeration and its accounting, candidate union and sorting, final target
validation, exclusions, and requiredness.
A private glob-specific preparation operation owns the patterned branch's work
for one directory entry: matching and hidden-entry filtering, lexical/access path
construction, containment checks, target inspection and discovered-link policy,
intermediate-directory eligibility, host/native access-path selection, labels,
and candidate construction. Its result distinguishes a selected candidate, an
ordinary skip, and a diagnostic. It receives the facts needed for that entry
rather than mutating the selector's candidate set or surrounding traversal state.
Use the existing filesystem access and link-resolution helpers. Directory
selection already has `prepareImportDirectoryEntry`; it remains a separate owner
because its recursion, matching, absence handling, and traversal state differ.
Share a smaller operation only where it actually expresses the same semantics
and reduces duplication. No common visitor or discovery strategy is needed for
this extraction.
### Behavior To Preserve
Preserve the current distinctions and processing order documented by
[explicit imports](../config.md#explicit-imports) and exercised by the selection
tests:
- Literal components remain explicit selections; discovery filters apply at
their existing wildcard boundaries. Moving matching or hidden filtering after
filesystem inspection would change which errors are observable.
- Keep lexical candidate identity separate from resolved access identity.
Native source identity and relative imports retain namespace spelling; host
access retains its existing canonicalization and containment behavior.
- Keep discovered-link follow rules, intermediate-directory eligibility,
missing-target handling, and structured diagnostic sites/classifications.
Do not make glob and directory rules identical as a side effect of extraction.
- Preserve deterministic candidate and error order, union/deduplication,
directory-entry accounting, and the existing point of source admission.
Final selected-file validation remains before exclusions where it is today;
exclusions do not hide discovery or resource errors.
- Retain the preceding sprint's distinction between missing-on-open directories
and failures after opening, along with operation-local completed-link reuse.
The intended benefit is that a maintainer can understand traversal separately
from entry eligibility and identity preparation. This is a readability change;
it does not select a different search algorithm or claim a speedup.
## Facade-To-Engine Transport
### Target Representations
The root keeps its public `ImportDefaults`, `PlacementMode`, `Options`, `Input`,
and `LayeredOptions` types. Explicit conversions remain appropriate at this
boundary; consumers must not acquire engine type identity, fields, or methods.
Inside the engine, consolidate the equivalent `ImportDefaults` transport and
private `importDefaults` records into one presence-aware input-defaults
representation. Carry that representation through the ordinary and layered
options, normalized request, and acquisition policy instead of translating the
same five fields between equivalent engine records.
Keep `resolvedImportPolicy` distinct: it represents concrete per-declaration
choices, including selector-specific rules, rather than another copy of optional
load-wide input. Likewise, registration compilation and the normalized loading
request have real responsibilities and are not replacement targets.
Make placement and defaults straightforward to construct on the existing engine
option/input records. Remove decorator-style bridges made unnecessary by that
construction surface. Exporting fields or types within `internal/engine` where
needed by the root converter does not make them part of ConfigKit's supported
public API. Keep explicit enum conversion and preserve unknown values until the
existing option validation can report them; conversion must not silently choose
a default or move validation into facade setters.
### Ownership And Copying
Make the actual mutable ownership boundaries identifiable. Public load calls
continue to snapshot pointed-to default booleans and the pattern slice before
source acquisition, as specified by the [public
declaration](../../lifecycle.go). Preserve omitted, explicit false, nil pattern,
and non-nil empty pattern as distinct inputs where they are distinct today.
Review the entire handoff, not just the duplicate type definitions: root
conversion in [conversion.go](../../conversion.go), ordinary construction in
[lifecycle.go](../../lifecycle.go), layered construction in
[layered.go](../../layered.go), engine options and normalization, acquisition
policy construction, and declaration resolution in
[import_spec.go](../../internal/engine/import_spec.go).
Remove snapshots that merely recopy already-owned read-only defaults as they
cross those private representations. Keep copies needed when establishing
independent mutable ownership, including any independently owned resolved
policy. The target is explainable ownership and less redundant work, not an exact
copy count or removal of copying throughout the library. Shared defaults within
one load can be read-only by implementation discipline; this creates no new
caller immutability or concurrency requirement.
Preserve ordinary and layered option preflight before source reads and application
hooks, independent declaration overrides, transitive/underlay propagation,
selected-overlay behavior, destination normalization, and invalid-enum
diagnostics. Keep callback, source, path, secret, and returned-record conversions
whose ownership or domain meaning is not redundant transport.
The intended benefit is fewer concepts, field mappings, and construction paths
to maintain. Reduced copying is useful but does not establish an end-to-end
performance guarantee.
## Documentation Ownership
Internal documentation describes the resulting discovery and transport
boundaries once implemented. Public declarations continue to own the existing
snapshot and type contracts; this effort selects no new public behavior.
The corresponding C03/F07 future-work entries serve as navigation to this
roadmap rather than parallel target or progress inventories. Unrelated future
candidates remain separate. The implementation plan owns the documentation
updates and retirement of replaced private representations and helpers.
## Validation And Completion
Follow the [testing policy](../policy/testing.md) and [development
guide](../development.md#baseline-validation). Reuse the existing glob selection,
directory/link failure, import-default, placement, public facade, and dependency
boundary coverage. Add a focused behavioral case only where a concrete
extraction or ownership risk lacks protection.
For discovery, verify literal versus wildcard filters, host/native identity,
contained and escaping links, optional absence versus read/close failures,
ordering, exclusions, regular-file checks, and existing resource accounting. For
transport, verify nil/empty and explicit-false distinctions, mutation of caller
default storage after the load snapshot through a supported callback, invalid
options before input consumption, both loading APIs, and transitive/underlay
defaults. Existing conversion-focused tests may change with private construction;
preserve the useful ownership behavior rather than fossilizing a helper's shape.
Completion requires an easier-to-follow selector, one engine input-defaults
representation, fewer redundant mapping/construction sites, justified remaining
copies, and preserved public behavior under the relevant checks. Review source
and ownership flow as well as passing tests; moving complexity into a helper or
renaming a duplicate record alone is insufficient.
No benchmark campaign, complexity score, allocation threshold, exact filesystem
call trace, new resource limit, or additional release gate is required. This work
adds no dependencies, caches, background activity, or new consumer controls, and
does not reopen other audit findings or deferred performance experiments. Routine
helper signatures, names, and file placement belong to the implementation plan;
no significant product or architectural decision remains open for this scope.

View File

@@ -1,905 +0,0 @@
# Sequence Implementation Review Remediation
Status: findings 1–17 are complete. The algorithm audit below identifies six
additional open findings, numbered 18–23. This record introduces no new release
gate.
Reviewed commit: `0be7cdb00eff576fda6b30c0600b2134d4e2e086`.
Implementation range: `bc6b977..0be7cdb`.
Authority: the [sequence roadmap](sequences.md),
[implementation plan](implementation.md),
[ADR 0017](../adr/0017-collection-sequence-policy.md), and
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
The public facade, shared identity/composition machinery, scoped policies,
identified-record inheritance, post-decode CLI phase, and final registered
identity validation are present. The findings below concern remaining
correctness, contract-alignment, documentation, and scaling gaps.
Validation on the reviewed commit:
- `./scripts/check.sh` passed with Go 1.26.10 on Linux.
- `go test -race ./...` passed.
- Public-API probes outside the repository reproduced findings 1–9. The probes
used labeled byte sources, defaults, and an in-memory filesystem; they did not
change implementation files or use external services.
- Finding 10 follows directly from the maintained text; finding 11 follows from
the prefix-scan loop, not a timing comparison or performance threshold.
Validation after the initial remediation: `./scripts/check.sh` and
`go test -race ./...` passed with Go 1.26.10 on Linux. The original public-API
probes for findings 1–9 now demonstrate the required corrected behavior.
Validation after integration remediation: the everyday check, full race suite,
and local documentation/public-boundary checks passed with the same toolchain.
The original and follow-up public-API probes demonstrate corrected behavior for
findings 1–9 and 12–15, including unrelated underlay payload at an old version
index. The accumulated-copy correction is verified by code inspection and
ownership regressions; the contract corrections are verified against the
implemented operations and replay. Each additional finding has its own completion
record and commit.
P1 denotes the highest-priority correctness issue here; P2 denotes normal-priority
work needed to complete the selected behavior. Preserve the agreed scope while
fixing these issues. In particular, do not resolve failures by restricting valid
identity fields, requiring repeated declarations, changing merge semantics, or
adding limits.
## 1. Preserve Retained Explicit Values In Nested CLI Mapping Assignments — P1
Status: [x] Complete.
Completion: override metadata now reads the actual target subtree through one
shared lookup. Ordinary/layered regressions preserve retained values, including
zero and false, their explicit origins, and absence-based resolver behavior.
Verified `go test ./internal/engine -run 'Override|CLI' -count=1` and
`./scripts/check.sh`.
Owner: [`applyDecodedOverrides`](../../internal/engine/override_decode.go),
especially lines 59–60 and the presence/origin update helpers.
The helpers receive the complete configuration projection as `existing`, but
interpret it as the subtree at the override path. A partial nested mapping
therefore treats retained input children as absent and drops their presence and
origins. `fillDefaultOrigins` subsequently labels those retained values as defaults.
Reproduction: load the following into ordinary nested Go structs:
```yaml
settings:
nested:
a: 1
b: 2
```
Apply an override at `[settings]` with value
`map[string]any{"nested": map[string]any{"a": 3}}`. The typed value of `b`
initially remains 2, but `Metadata.Lookup([settings,nested,b])` reports
`Explicit: false` and a default origin. Calling `Default` for that field in
application resolution then changes the explicitly supplied 2 to the fallback
value. This violates presence-based defaulting, not merely presentation.
Required remediation: use the actual existing target subtree when updating
presence/origins, preserving retained descendants and removing genuinely replaced
ones. Keep override batches detached and preserve the selected post-decode order.
Add regressions for nested partial mappings, retained zero/false values, metadata,
and an absence-based resolver default that must not overwrite retained input.
## 2. Read Upsert Origins At Input Paths And Write Them At Output Paths — P2
Status: [x] Complete.
Completion: sequence origin copying now separates incoming and effective paths
for precedence and inheritance. A regression covers reordered matches, appended
records, composite origins, and actual resource reads from distinct declaring
directories. Verified focused origin/inheritance/overlay/provenance tests,
`go test -race ./internal/engine`, and `./scripts/check.sh`.
Owners: [`applyOverlaySequenceEvidence`](../../internal/engine/overlay.go),
particularly lines 369 and 381, and the analogous sequence-origin copying in
[`inheritance_collection.go`](../../internal/engine/inheritance_collection.go).
The overlay helper supplies an output-index path to an origin lookup whose keys
are still incoming-index paths. When those positions differ and another incoming
record occupies the output index, metadata takes that other record's source
location. The same defect was reproduced through mapping-sibling inheritance;
its helper uses the same single-path copying pattern.
Reproduction: an underlay supplies IDs `[a,b]`; the main source declares `key: id`
and supplies `[b*,a*]`. The result is correctly `[a*,b*]`, but the value origin
for final `a*` is the source line of `b*`, and vice versa. In the probe the two
value lines were 7 and 9; final index 0 incorrectly reported line 7 instead of 9.
With separately authored records this can select a different source/context.
Required remediation: use the operation's input-to-output projection for origin
subtree reads as well as writes. Preserve composite contributing origins, not
just node-location fallbacks. Test reordered upserts and appended records with
distinct source files/locations, including nested fields, inheritance operations,
and declaring resource contexts.
## 3. Apply Tier Sequence Operations To Historical Contributions — P2
Status: [x] Complete.
Completion: per-source history now composes strict item projections with the
tier operation, including nested application prefixes and local sequence replay.
Regressions cover all three operations, multiple peers, retained records, removed
fields, source attribution, and a subsequent overlay without a false decoding
event. Verified focused history tests, `go test -race ./internal/engine`, and
`./scripts/check.sh`.
Owner: [`applyOverlayWithCollectionPolicy`](../../internal/engine/overlay.go),
lines 76–84, and existing sequence-history projection/replay.
For composed tiers, `applications` is nonempty. This branch appends the tier's
original strict-composition applications without incorporating the newly computed
precedence sequence descriptors. Replay consequently performs a whole-list
replacement or strict append rather than the actual cross-tier upsert/append.
The later decoding reconciliation conceals the mismatch by attributing a
structural combination to typed defaults/decoding.
Reproduction: lower `[a,b]` and higher `[b*,a*]` correctly yield `[a*,b*]`.
`Explain([items])` instead shows the main contribution producing `[b*,a*]`, then
a `decoding` event reordering it to `[a*,b*]` with the reason "applied retained
defaults and canonical typed decoding". No decoder performed that reordering.
Required remediation: project each retained per-source application through the
tier's selected operation while keeping peer composition distinct from tier
precedence. Do not collapse authored sources or fake decoding events. Cover
upsert, append, unmatched retained records, reordered matches, removed fields,
multiple higher-tier peers, and a subsequent selected overlay. Replay should
agree with structural assembly before real decoding reconciliation.
## 4. Reproject Nested Source Destinations After Cross-Tier Item Movement — P2
Status: [x] Complete.
Completion: layered assembly projects incoming source destinations and version
classification evidence with the same item assignments used for values/history.
Earlier contributions retain their historical coordinates, physical identities
and contexts. Main/underlay regressions cover reordered upserts, append, replace,
nested helpers, and source-local version evidence. Verified focused destination,
origin and source-version tests, focused race coverage, and `./scripts/check.sh`.
Owners: [`projectAcquiredDestinations`](../../internal/engine/imports.go) and the
sequence-operation handoff in [`assembleLayeredInput`](../../internal/engine/load_request.go).
Source destinations are projected while composing their own tier, but are not
reprojected when that tier's records move to accumulated-list positions. Nested
helper source records can therefore point at a different final record.
Reproduction: lower `items` contains `[a,b]`. Main imports a single `b` record
with `placement: sequence-item` into `[items]`; that record imports a helper
mapping supplying its value. Upsert retains `a` at index 0 and places the higher
`b` at index 1. `Sources()` nevertheless reports the surviving helper's destination
as `/items/0`, its tier-local position, rather than `/items/1`.
Required remediation: carry the sequence operation's projection through nested
source-destination/evidence updates at precedence boundaries. Keep physical
source identity and passive resource contexts unchanged, and preserve historical
meaning for displaced contributions rather than relabeling them as survivors.
Test a retained lower record followed by an imported higher record, reordered
matches, and nested helpers. Check version-evidence projection where applicable.
## 5. Honor Identity Reset On Actual Inheritance Replacement — P2
Status: [x] Complete.
Completion: inheritance resolves identity against actual child-value presence,
discarding parent YAML identity for an explicit replacement while preserving
agreement for retained values and authoritative application constraints. Added
policy-state and public-load regressions for changed/unkeyed/non-sequence
replacements, application agreement/conflict, and mode-only/append/upsert cases.
Verified focused inheritance tests and `./scripts/check.sh`.
Owners: [`inheritanceCollectionResolution` and `mergeInheritanceCollectionPolicies`](../../internal/engine/inheritance_collection.go),
especially lines 79–80 and 121.
These paths reconcile the parent's YAML identity before accounting for actual
replacement, and treat the parent's collection as retained even when the child
supplies a replacing value. This is stronger than the selected identity lifetime
and differs from source-tier replacement.
Reproduction:
```yaml
configkit:
sequences:
- destination: [base, items]
key: id
- destination: [child, items]
key: name
mode: replace
base:
items:
- id: a
value: parent
child:
extends: base
items:
- name: b
value: child
```
Loading fails with `sequence.conflict` at `/child/items`. It should succeed:
actual whole-sequence replacement may discard the parent's YAML identity and
establish `name`. Authoritative application constraints must still apply.
Required remediation: distinguish actual replacement from a mode-only declaration
before identity agreement and transfer. Test a changed key, replacement with no
new key becoming unkeyed, permitted non-sequence replacement, and an agreeing or
conflicting application registration. Preserve rejection of identity changes
without a replacement value and during append/upsert.
## 6. Enforce Collection Operations Beneath Newly Added Ancestors — P2
Status: [x] Complete.
Completion: copying a mapping now visits nested collection operations with absent
lower values, preserving ordinary ancestor removal and whole-record semantics.
Incoming record-owned policy transfers through the same operation. Regressions
cover absent/empty/null ancestors, identity and kind failures, empty and valid
initialization, and later inheritance using an imported item's nested identity.
Corrected an older test that deferred the required error until a later tier.
Verified focused merge/collection/inheritance tests, focused ownership race
coverage, and `./scripts/check.sh`.
Owners: [`mergeRecursiveMappings`](../../internal/engine/recursive_merge.go) and
[`mergeOverlayMappingsWithCollectionPolicy`](../../internal/engine/overlay.go).
When an incoming ancestor mapping is absent below, the recursive merger clones
the entire subtree and invokes only its ancestor-level added hook. Nested
collection resolutions are not visited. This bypasses their operation rules;
the subsequent declaration-only policy update does not replace those checks.
Reproduction: combine a lower `{}` with this main tier:
```yaml
configkit:
sequences:
- destination: [group, items]
mode: append
group:
items:
- id: a
```
Loading succeeds despite append having no identity definition. Placing the same
sequence directly at `[items]` correctly fails. The validity of an operation
must not depend on whether an enclosing mapping already existed.
Required remediation: account for applicable nested collection policies when
adding/copying an ancestor subtree, including owned-policy transfer. Test absent
and present enclosing mappings, append/upsert without identity, explicit empty
lists, invalid non-sequence contributions, and valid identified initialization.
Do not turn this into a general path-existence or shape constraint: ordinary
ancestor replacement/removal retains its documented meaning.
## 7. Validate Final Typed Identity Fields As Payload, Not Directives — P2
Status: [x] Complete.
Completion: shared identity lookup now distinguishes structural records from
effective payload/reference mappings. Final validation includes literal `extends`
fields while structural source checks still exclude actual directives. Facade
regressions cover defaults and recorded resolution, string/null components, and
duplicate rejection. Verified those regressions and existing structural identity
and inheritance coverage through `./scripts/check.sh`.
Owners: [`validateFinalSequenceIdentities`](../../internal/engine/final_identity.go)
and [`newSequenceMappingIndex`](../../internal/engine/sequence_identity.go).
The final validator reuses structural record lookup, which excludes every
`extends` entry. At this phase there is no structural processing: typed defaults
and application resolution can produce ordinary payload with that literal name.
The roadmap expressly distinguishes structural-control exclusion from final
typed-payload validation; this reuse introduces an extra restriction.
Reproduction: a defaults-only `Catalog` with `Items []map[string]any` containing
`{"extends": "application-data"}`, registered with
`SequenceIdentityAt(Keys("items"), "extends")`, fails with a missing identity
field at `/items/0/extends`, even though the effective field is present.
Required remediation: distinguish structural-payload lookup from final payload
lookup. Preserve rejection when a source tries to use an actual consumed
directive as identity. Test final fields supplied by defaults and recorded
resolution, including a null component, without adding a broader name ban.
## 8. Retain Nonfatal Main-Version Diagnostics In Layered Loads — P2
Status: [x] Complete.
Completion: layered assembly returns nonfatal main-version diagnostics on success
and alongside later failures; the shared caller stops only for errors. Regression
coverage verifies ordinary/layered warning parity, exactly one checker call, and
warnings retained with later structural or decoding errors. Verified through
`./scripts/check.sh`, including existing source-version ordering coverage.
Owner: [`assembleLayeredInput`](../../internal/engine/load_request.go), lines 143–152.
The moved default-mode version-check call returns its diagnostics only when they
contain errors. Warning-only diagnostics are discarded. Ordinary loading still
retains the same warnings.
Reproduction: a `VersionRule.Check` that calls
`validation.Warning(Keys("version"), "test warning")` and returns nil yields one
`version.warning` through ordinary `Load`, but zero diagnostics through
`LoadLayered` with the same main input and default source-version mode.
Required remediation: carry warnings from the single main-tier check into the
shared diagnostic accumulator on successful layered loads. Preserve checker
ownership, invocation count, and the selected acquisition/composition ordering.
Add ordinary/layered parity and warning-plus-later-error regressions.
## 9. Identify Conflicting Application Registrations In Diagnostics — P2
Status: [x] Complete.
Completion: definition validation retains original one-based registration
positions and names both positions in each overlap diagnostic. No input path or
new public diagnostic structure is invented. A mixed-selector regression verifies
multiple conflicts even after an earlier invalid registration is skipped.
Verified registration/facade coverage through `./scripts/check.sh`.
Owner: [`compileSequenceIdentityPolicy`](../../internal/engine/sequence_registration.go),
lines 119–128.
Incompatible dynamic overlaps produce only a generic `options.invalid` message,
an empty configuration path, and no related information. Neither registration
is identified. The roadmap requires definition-time errors to identify the
conflicting registrations without inventing a concrete input destination.
Reproduction: register `collections -> AnyMappingKey -> items` using `id`, and
`collections -> foo -> items` using `name`. Even with `NoSource`, the error is
only "overlapping sequence registrations must use the same identity fields".
With multiple registrations the caller cannot identify the conflicting pair.
Required remediation: retain original registration positions and identify both
in safe definition diagnostics, for example through positional message context.
No new public diagnostic structure is required solely for this. Test mixed exact
and dynamic registrations and multiple conflicts, without asserting complete
English wording or exposing identity values.
## 10. Remove Stale Public Contract Descriptions — P2
Status: [x] Complete.
Completion: the public definition comment now describes final registered-identity
validation and its shape/lifetime boundaries. Selected-overlay prose explicitly
separates ordinary replacement from collection policy and links to the canonical
operation/presence tables. Verified against the implementation, local-link tests,
and `git diff --check`; no runtime behavior changed.
Owners: [`Definition.SequenceIdentities`](../../lifecycle.go), lines 18–21, and
the [selected-overlay contract](../config.md#selected-overlays).
The exported definition-field comment still says registrations do not validate
identities introduced by defaults, CLI, or callbacks. Final validation is now
implemented and the registration constructors correctly document it.
The selected-overlay table and following paragraph also still unconditionally
describe sequences as atomic replacements and `[]` as clearing. A later paragraph
describes collection operations, but does not make the earlier unconditional
claims accurate: identified collections infer upsert, and `[]` then contributes
no records rather than clearing.
Required remediation: make the symbol comment describe its implemented final
guarantee and boundaries, and qualify the overlay table/paragraph for collection
policy. Link to the canonical collection tables rather than duplicating them.
Keep YAML-only enforcement distinct from registered final validation.
## 11. Remove The Quadratic Prefix Scan In Item Policy Projection — P2
Status: [x] Complete.
Completion: assigned sequence ranges now supply item offsets directly. Handoffs
without a range lazily accumulate prefix lengths, visiting each preceding
contribution once rather than rescanning every prefix. Regression coverage keeps
inline and policy-less contributions in the correct positions with complete,
partial, or absent ranges. Existing ownership/placement tests remain intact.
Verified focused item-policy tests, `./scripts/check.sh`, `go test -race ./...`,
and inspection of the remaining error-only prefix-scan callers. No new benchmark
fixture or performance threshold was needed for this bounded change.
Owner: [`projectNestedCollectionPolicies`](../../internal/engine/imports.go),
especially line 968; the scanned helper is `priorSequenceLength` in the same file.
For every item with local collection policy, the loop scans all preceding
contributions to calculate `itemIndex`, then overwrites that result when the
already-indexed sequence range is available. For N such items this performs
`0 + 1 + ... + (N-1)` contribution visits, excluding path lookup costs, despite
the common case already having the exact assigned index. This is introduced
quadratic work in the feature's independently authored-record path.
Required remediation: use the existing assigned range first. If a genuine
fallback is necessary, maintain per-destination prefix lengths in one pass
rather than rescanning every prefix. Preserve scope and ordering semantics;
do not add limits, pooling, or a new public cache. Keep ownership/placement
regressions and add a representative many-item/local-policy benchmark only if
needed to exercise the path. A wall-clock threshold is not required.
## Integration Audit Baseline And Scope
Reviewed commit: `8d40983271de5239312eb0a2896737be88c5abdc`.
This follow-up reviews the interaction of collection policy, identified-record
inheritance, dynamic registrations, and post-decode CLI assignment with existing
source placement, source-version checking, typed decoding, inspection, resource
contexts, and ownership boundaries. The accepted ADRs and current configuration
contract remain the authority; the findings require integration corrections,
not additional consumer restrictions or new feature choices.
Validation:
- `./scripts/check.sh` and `go test -race ./...` pass with Go 1.26.10 on Linux.
- Isolated public-API probes outside the repository reproduce findings 12–15.
They use labeled byte sources and an in-memory filesystem, with no external
services, real credentials, or repository implementation changes.
- Finding 16 follows from the per-item deep-copy loop; no timing claim or
performance threshold is needed. Finding 17 follows from maintained contract
text that contradicts the implemented collection operations.
- Positive comparisons include sequence-valued identity replacement, ancestor
retirement through source tiers, quoted versions after item movement, and an
append overlay that preserves the main record's version.
## 12. Complete Identity Retirement Across Replacement Shapes — P2
Status: [x] Complete.
Completion: source composition and inheritance now prepare eligible lower policy
from actual replacement paths before agreement. Removed identity and record state
cannot constrain a new pending key or reappear in later inheritance. Shared mode
selection preserves explicit-mode persistence and authoritative registrations.
Regressions cover null/scalar/mapping/list replacement, ancestor removal with a
new key, reintroduction, and retained/application-constrained conflicts. Verified
focused retirement/inheritance tests, engine ownership race coverage, and
`./scripts/check.sh`.
Owners: [`resolveStrictCollectionPolicies`](../../internal/engine/compose.go),
especially its `sequencePresent` reset condition;
[`mergeInheritanceCollectionPolicies`](../../internal/engine/inheritance_collection.go);
and ancestor-replacement handling in
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go).
Policy agreement and retirement still treat some actual replacements as retained
collections. This has three observable effects under the same selected lifetime
rule:
1. Source precedence accepts a replacement sequence with a new YAML key, but
rejects the equivalent replacement by null or a scalar. For example, a lower
tier declaring `key: id` for `[items]` with `items: [{id: old}]`, followed by
a main tier declaring `key: name, mode: replace` with `items: null`, fails with
`sequence.conflict` at `/items`. Replacing null with `42` also fails; replacing
it with `[{name: new}]` succeeds. Null/scalar replacement should discard the
lower identity and retain the newly declared key as pending policy, subject
to the application's schema and constraints.
2. Removing an ancestor while declaring a new pending key for its removed
descendant fails before the replacement occurs. A lower
`group: {items: [{id: old}]}` with key `id`, followed by `group: null` and a
root declaration of key `name` for `[group, items]`, fails at `/group/items`.
The analogous `base`/`child` inheritance case fails at `/child/group/items`.
The old collection is being removed rather than retained.
3. Inheritance can resurrect removed identity even without a conflicting new
declaration. This input fails with a missing `id` at
`/grandchild/group/items/0/id`:
```yaml
configkit:
sequences: [{destination: [base, group, items], key: id}]
base: {group: {items: [{id: old}]}}
child:
extends: base
group: null
grandchild:
extends: child
group: {items: [{name: new}]}
```
The same remove/reintroduce operations through source tiers succeed. The
inherited descendant identity remains in policy state after its enclosing
mapping is removed, so the grandchild incorrectly receives the old key.
Required remediation: account for actual value and ancestor replacement before
deciding which lower identity participates in agreement, and retire removed
descendant/record-owned state during inheritance as during source precedence.
Retained inheritance templates must not reestablish discarded identity later.
Preserve authoritative application constraints, independently owned mode
persistence, pending declarations, and rejection of key changes when values are
genuinely retained. Cover direct non-sequence replacement, ancestor removal with
a new pending key, and removal followed by reintroduction across both contexts.
Do not require repeated keys or narrow permitted null/scalar shapes to avoid
these cases.
## 13. Keep Metadata History In Post-Decode CLI Order — P2
Status: [x] Complete.
Completion: metadata history is assembled from the pre-CLI source view, with CLI
events appended separately; inheritance is projected before CLI and resolution.
Effective fields still describe post-CLI values. Regressions verify chronological
origins and resolver dependencies for ordinary/layered mapping and record
inheritance, including equal-valued assignments. Verified history/override
coverage and `./scripts/check.sh`.
Owners: [`historyWithInheritance`](../../internal/engine/inheritance_history.go)
and the source/CLI metadata handoff in
[`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go).
The value explanation follows the new lifecycle, but `Metadata.History` still
inserts inheritance after stored CLI changes. `historyWithInheritance` splits
only at `LayerResolution`, reflecting the former CLI-before-inheritance order.
The reconstructed source view can also append an authored source origin after
the CLI event.
Reproduction: load `base: {value: parent}` and
`child: {extends: base, value: authored}`, then override `[child, value]` with
`"cli"`. The final value and `Metadata.Lookup` correctly report CLI provenance.
`Explain` reports source → inheritance → CLI. `Metadata.History` instead reports
source → CLI → source → inheritance and ends with a source/inheritance origin.
This violates chronological metadata history and disagrees with the effective
origin and the value explanation.
Required remediation: assemble metadata history in the implemented source,
inheritance, CLI, and resolution order, preserving the real origins and
dependencies of those events. Cover ordinary/layered loading, mapping and record
inheritance, equal-valued CLI writes, and a later recorded resolver change at the
appropriate existing test owners. Do not impose a new exact event-count or
message-format contract.
## 14. Carry Main-Version Coordinates Through Sequence Movement — P2
Status: [x] Complete.
Completion: the effective version rule now follows the main declaration through
strict placement, tier combination, and inheritance without changing source-local
checker/evidence paths. Underlay and overlay ownership checks use assigned record
positions, including whole-record removal; integer conversion remains confined
to the actual main header. Regressions cover mounted/indexed paths, appended and
matched records, integer/string parity, unrelated version payload, imported
underlay claims, and inheritance. Corrected an older ownership fixture to target
the actual matching main record. Verified source-version regressions,
`./scripts/check.sh`, and `go test -race ./internal/engine`.
Owners: [`effectiveSourceVersionRuleForPlacement`](../../internal/engine/source_version.go),
the main-tier projection handoff in
[`assembleLayeredInput`](../../internal/engine/load_request.go), and use of
the effective rule in [`executeLoadRequest`](../../internal/engine/load.go).
Source destinations and version-evidence sensitivity paths follow item movement,
but the rule used for integer-version decoding retains the original item index.
Consequently an accepted integer header can fail decoding after an otherwise
valid upsert placement.
Reproduction with the public API:
- Main input is `id: main` and `version: 4`, placed as one sequence item at
`Keys("items")`.
- A lower input contributes the sequence item `id: lower` at the same destination.
- Register identity `id` for `[items]`, select `SourceVersionModeIfPresent`, and
use `VersionRule.Path = Keys("version")` with an accepting checker.
- Decode into records with `ID string` and `Version string`.
The checker accepts `4` exactly once. Upsert places the main record at index 1,
but decoding fails with `decode.type` at `/items/1/version`. The integer-to-string
exception is still tied to `/items/0/version`. Quoting the identical version as
`'4'` succeeds. A source-local indexed header path, without item placement,
reproduces the same problem when its record moves during upsert.
Required remediation: carry the main declaration's effective coordinates through
the existing item projections into decoding and audit the associated ownership
protection handoffs for the same stale-coordinate assumption. Preserve
source-local checker/evidence paths, checker invocation count, main-only
ownership, and the narrow integer-to-string exception. Cover moved existing and
newly appended main records, integer/string parity, and protection of the actual
main header after movement. Do not prohibit supported indexed paths or mounted
main records as a workaround.
## 15. Exclude Parent Identity From Inherited History Payload — P2
Status: [x] Complete.
Completion: retained record-inheritance operations now exclude parent identity
from their payload and origin projection. Replay preserves the child's own
identity before taking field, record, sequence, or root snapshots, without
inventing inheritance events for retained identity fields. Regressions cover
single and compound identities, explicit child provenance, ordinary inherited
payload, and the parent's original history. Verified inheritance/inspection
coverage, `./scripts/check.sh`, and `go test -race ./internal/engine`.
Owners: inheritance operation construction in
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go),
[`appendInheritanceExplanation`](../../internal/engine/inheritance_explain.go),
and transferred-origin projection in
[`inheritance_history.go`](../../internal/engine/inheritance_history.go).
Identified-record value construction excludes the parent's identity fields, but
the retained history operation still supplies the complete parent record and
its identity origins to the older mapping-inheritance replay machinery.
Reproduction:
```yaml
configkit:
sequences: [{destination: [items], key: id}]
items:
- id: base
value: parent
- id: child
extends: {id: base}
```
The effective child ID and origin are correct. However,
`Explain(Keys("items").Index(1).Key("id"))` reports `child` → `base` → `child`,
attributing the intermediate value to an inheritance operation and the parent's
ID location. `Metadata.History` likewise claims an inherited parent-ID
contribution. That identity is expressly excluded from inherited payload; it was
never an applied value at the child path.
Required remediation: make history replay and transferred-origin projection use
the same identity exclusion as value construction. Preserve the child's authored
identity and presence, ordinary inherited payload history, and the parent record's
own source history at its original path. Cover compound identities and identity
fields alongside inherited payload at both record and ancestor queries; keep the
existing inspection API and sensitivity behavior.
## 16. Avoid Deep-Copying Accumulated Item Policies Per Record — P2
Status: [x] Complete.
Completion: item projection now takes one owned policy copy per destination,
updates that local state incrementally, and stores it once after projection.
Incoming scopes still cross detached ownership boundaries. Inspection confirms
that accumulated record scopes are no longer deep-copied per item; no timing
threshold or benchmark campaign was needed. Existing stable-index, sparse-range,
fallback-offset, and ownership coverage passes, with a new regression for
interleaved destinations, distinct record owners, and sibling/cross-load mutation
independence. Verified focused item-policy tests, `./scripts/check.sh`, and
`go test -race ./...`.
Owners: [`projectNestedCollectionPolicies`](../../internal/engine/imports.go)
and its use of copying `collectionPolicyScope.at` / `set` helpers in
[`collection_policy_state.go`](../../internal/engine/collection_policy_state.go).
The prefix-scan correction in finding 11 is present, but a separate quadratic
cost remains on the same independently authored-record path. For every imported
item with local policy, `at` deep-clones the destination's accumulated record
order and every retained record's nested scope. After adding one owner, `set`
deep-clones the growing policy again. With N fixed-size item-local policies at
one destination, this copies approximately `1 + 2 + ... + N` retained scopes,
even when every assigned sequence range is available.
This follows directly from the loop and `cloneCollectionPolicy`; no busy-host
timing comparison is required. It is an integration cost from reusing detached
read/write helpers within private incremental construction, rather than from
the required final ownership copies.
Required remediation: construct each destination's owned record-policy state
without repeatedly cloning all earlier records, retaining necessary copies at
ownership boundaries. Preserve record ordering, distinct owners, sparse assigned
positions, fallback offsets, and cross-load detachment. Reuse the existing item
policy and ownership tests; add allocation/scaling evidence only where useful.
Do not introduce public caches, pooling, new caps, or performance thresholds.
## 17. Reconcile Legacy Sequence-History Contract Text — P2
Status: [x] Complete.
Completion: metadata and explanation prose now describe history through the
selected collection operation, assigned item positions, and displaced payload.
The text distinguishes sequence snapshots and atomic diffs from append/upsert
combination and links to the canonical operation tables. Whole-record upsert,
existing queries, and sensitivity guarantees are unchanged. Verified against
replay/combination code, local documentation-link tests, and `git diff --check`;
the unchanged runtime is covered by the preceding everyday and full race checks.
Owner: the metadata/provenance and explanation sections of
[`docs/config.md`](../config.md#layered-metadata).
The metadata section still says that a later tier supplying a sequence records
replacement at the sequence path and that history does not imply identity-based
merging across tiers. That describes the former unconditional replacement model,
not the implemented append/upsert history or the collection operation tables in
the same document. The explanation paragraph about atomic sequences also needs
review so readers can distinguish snapshot/diff granularity from collection
combination semantics.
Required remediation: describe history for the selected collection operation and
link to the canonical operation tables. Preserve the existing atomic sequence
snapshot/diff behavior and whole-record upsert semantics; correcting the prose
does not authorize recursive record-field merging, a new query API, or broader
history guarantees.
## Algorithm Audit Baseline And Scope
Reviewed commit: `c73daf891d0f29c96f7026bdabcccb571f44a751`.
Feature baseline: `4f941f8ba4c9a9ed0afa87b07fe6ba29f131d1c6`.
This audit examines the sequence identity/operation, scoped-policy, inheritance,
inspection, and post-decode CLI algorithms, including the completed corrections
above. The intervening discovery roadmap commits add no implementation to audit.
The accepted scheduler, deterministic ordering, exact numeric normalization,
selector trie, and deliberately pairwise registration-overlap validation remain
selected design choices; they are not findings here.
Evidence and validation:
- Code inspection establishes the complexity of findings 19–23 on bounded-depth
inputs with linear-sized results. Required result/history copies and genuinely
growing inheritance output are excluded from the alleged excess work. Bounds
describe internal operations, not new public performance guarantees.
- An isolated public-API probe reproduces finding 18 using an in-memory
filesystem, without external services or real credentials.
- Temporary tests supplied through a Go overlay, outside the repository, checked
all 768 combinations of append/upsert/replace over the 16 unique-ID lists drawn
from three IDs. Stable ordering, duplicate rejection, and whole-record payload
replacement agreed with an independent reference calculation.
- A structural overlay test confirmed that 4, 8, and 16 one-item contributions
retain 10, 36, and 136 reverse-index cells respectively in `indexSequenceRanges`.
These are representation counts, not timing or real-world benchmark results.
- `go test ./internal/engine -run 'Sequence|Collection|Inheritance|Override|SourceVersion|Metadata|History' -count=1`
passed with Go 1.26.10 on Linux. Passing existing tests does not cover the
incorrect selector scope or establish efficient growth.
The following are focused corrections within the selected model. This review
changes only the remediation record.
## 18. Match Application Registrations In Effective Item Coordinates — P2
Status: [ ] Open.
Owner: `composePendingItems` in
[`imports.go`](../../internal/engine/imports.go), especially its local
`composeDocumentsWithCollectionPolicy` call; the current
`TestPendingItemCompositionAppliesApplicationIdentityConstraints` in
[`item_policy_test.go`](../../internal/engine/item_policy_test.go) also needs review.
Pending item/helper documents are composed in item-local coordinates while the
unchanged global application selector policy is supplied to their composer.
A registration for root `[prompts]` therefore also constrains an unrelated
`[items, 0, prompts]` collection before placement. This contradicts complete,
effective-destination matching and makes an otherwise unused registration reject
valid configuration. Application selectors do not traverse sequence indexes.
Reproduction: main imports `item.yml` with destination `[items]` and placement
`sequence-item`. The item contains `id: outer` and
`prompts: [{text: payload}]`. Loading succeeds without a registration; adding
only `SequenceIdentityAt(Keys("prompts"), "id")` fails with `compose.invalid`
at `/items/0/prompts/0/id`, although no root `prompts` collection exists.
Required remediation: separate item-local YAML policy from application matching
in effective coordinates, including nested helper scopes. Preserve applicable
outer collection constraints without reinterpreting global selectors as local
ones. Add ordinary/layered regressions for unused root and dynamic selectors
alongside imported item-local collections. Correct tests that pin the accidental
scope; do not change selector meaning or require consumers to remove valid rules.
## 19. Avoid Dense Global-Position Storage For Sparse Item Projections — P2
Status: [ ] Open.
Owners: `newSequenceItemProjectionIndex` in
[`sequence_projection.go`](../../internal/engine/sequence_projection.go),
`indexSequenceRange` / `projectedCompositionOrigins` in
[`compose.go`](../../internal/engine/compose.go), and contribution indexing in
[`history.go`](../../internal/engine/history.go).
The reverse projection slice is sized to `maxOutput + 1`, even for one incoming
item assigned to a late assembled position. `indexSequenceRange` materializes
these indexes for ordinary contiguous append ranges too. With N one-item sources
contributing to one sequence, reverse slices retain `1 + 2 + ... + N` cells.
Initialization time and simultaneously retained auxiliary storage are therefore
quadratic despite only N actual mappings. Origin/history projection constructs
the same representation through additional callers. Finding 16's policy-copy
correction does not remove this independent cost.
Required remediation: retain arithmetic start/end lookup for contiguous ranges
and use storage proportional to actual mappings for scattered positions, such as
a sparse reverse map. Audit shared callers and avoid rebuilding equivalent indexes
inside item queries. Preserve bidirectional lookup, absent positions, upsert
replacement evidence, ordering, and detached results. Verify the representation
on many one-item sources and noncontiguous upserts; add no timing threshold or
collection-size limit.
## 20. Remove Full Metadata Scans Per CLI Replacement Leaf — P2
Status: [ ] Open.
Owners: `updateOverridePresence`, `deletePresencePathSubtree`, and
`updateOverrideOrigins` in
[`override_decode.go`](../../internal/engine/override_decode.go).
Every replaced scalar or subtree scans all presence keys, then all origin keys,
to find matching prefixes. A single partial-mapping override updating N scalar
children in an N-field configuration performs quadratic map-entry visits. This
does not require N CLI sources or growing historical output: the same effective
maps remain approximately N entries throughout the update.
Required remediation: delete affected entries by walking the already known
existing subtree, or use one batch-local prefix index where needed. Retain
necessary copies at ownership boundaries without rescanning unrelated paths for
each leaf. Preserve retained explicit zero/false values, removal of replaced
descendants, null/kind changes, origin attribution, and detached loads. Use the
existing nested CLI and ownership regressions plus focused broad-mapping coverage;
do not change override semantics or impose batch limits.
## 21. Localize Inheritance Policy Work To The Relevant Scope — P2
Status: [ ] Open.
Owners: `inheritancePolicyScopeAtPath` in
[`inheritance.go`](../../internal/engine/inheritance.go), graph preparation in
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go),
and policy handoff/retirement in
[`inheritance_walk.go`](../../internal/engine/inheritance_walk.go).
Scope localization scans every entry in the supplied policy table for each
definition. Mapping graph preparation also clones the full supplied table, and
the walker passes its global table to each independent scope and scans that table
again during replacement. For S scopes or siblings and P unrelated collection
policies, these paths perform repeated S-by-P work and copies.
For example, N independent mapping groups can each contain a constant-sized
`base`/`child extends base` pair and one YAML collection declaration beneath its
base. Input, expanded values, and relationship evidence remain linear in N, but
each group's graph/localization visits or clones the other groups' policies.
This is separate from necessary expansion along a genuinely growing parent chain
and from the selected dependency scheduler.
Required remediation: partition or index policies by semantic scope once and
carry the relevant state through traversal, including efficient descendant
retirement as inheritance introduces or removes destinations. Clone owned
in-scope state rather than unrelated global state. Preserve record owners,
pending declarations, mode lifetime, destination-local registration matching,
and independent templates. Verify wide independent groups and nested item scopes;
do not replace the agreed scheduling algorithm or add global caches.
## 22. Index Fallback Location Lookup During Effective Projection — P2
Status: [ ] Open.
Owner: `documentWithMetadataLocations` in
[`override_context.go`](../../internal/engine/override_context.go), using
`nearestDocumentLocation` in
[`validation.go`](../../internal/engine/validation.go).
The new effective-projection walk calls the unindexed nearest-location helper
for each field lacking a real origin location, often once for its mapping entry
and again for its value. Each call starts at the fallback root and linearly scans
mapping entries. With N authored root fields and N different default-only root
fields, each default's failed lookup scans all N authored keys: quadratic work
for linear-sized input and output. The shared lifecycle calls this path even
without CLI assignments or a resolver.
Required remediation: share an indexed fallback lookup or traverse corresponding
containers together while carrying the nearest available location. Existing
`documentLookup` / mapping-index machinery can support bounded-depth lookups
without repeated wide scans. Preserve nearest-ancestor fallback, source IDs,
synthetic locations, secret-reference diagnostics, and publication ownership.
Exercise mixed authored/default-only broad mappings; do not weaken attribution
or skip needed location attachment to avoid the work.
## 23. Index Repeated Collection-Policy Document Lookups — P2
Status: [ ] Open.
Owners: `mergeOverlayMappingsWithCollectionPolicy` in
[`overlay.go`](../../internal/engine/overlay.go) and
`mergeInheritanceCollectionPolicies` in
[`inheritance_collection.go`](../../internal/engine/inheritance_collection.go).
These routines loop over collection policies/declarations and call plain
`documentAtPath` for each destination. That helper linearly searches every
mapping on the path. N independent collections below N sibling mapping keys
therefore cause quadratic mapping-entry searches when checking which lower
policies the incoming tier touches. The inheritance parent/child lookups and
retained-value declaration checks have the same pattern. YAML declarations alone
can establish these policies; the deliberately pairwise application-registration
overlap check is not involved.
Required remediation: reuse a document lookup built once for each stable input
tree, or use the already collected claims where their scope is sufficient.
Index a mutated result only after its value-changing phase, rather than retaining
a stale lookup. Preserve absent destinations, non-sequence pending declarations,
literal key/index distinctions, retirement, and error locations. Verify broad
independent collections across precedence and inheritance; no new public cache,
shape requirement, or performance gate is needed.

276
docs/roadmap/roadmap.md Normal file
View File

@@ -0,0 +1,276 @@
# Audit Remediation Roadmap
Status: target accepted; completed execution is recorded in the historical plan
at `bb5540b:docs/roadmap/implementation.md`.
Prepared: 2026-10-25. Planning baseline: `e1e94ee`.
This roadmap describes the target state and related work for the 29 entries
selected in the [consolidated audit review](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#settled-scope-decisions).
It is intended for maintainers reviewing the remediation's scope and architecture.
## Document Ownership
- [Archived audit](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md) records finding IDs, original evidence, detailed remedies and
acceptance cases, and the settled scope decisions. Its evidence has not been
independently reproduced by writing this roadmap.
- This document owns the combined target state, work areas, and design intent.
Finding IDs below provide traceability without repeating the full audit inventory.
- The historical implementation plan at
`bb5540b:docs/roadmap/implementation.md` records concrete stages, private
implementation choices, edits, validation commands, and completion evidence.
The [configuration contract](../config.md), public Go declarations, and
[architecture policy](../policy/architecture.md) remain the owners of behavior
and guarantees. Update their applicable implementation documentation when work
lands; this planning document does not redefine their contracts.
## Current Baseline And Targeted End State
The existing loading model remains the foundation. Public loading APIs delegate
to the shared engine lifecycle; acquisition retains source scopes for composition,
then precedence, inheritance, decoding, CLI overrides, application resolution,
secret lookup, and validation produce a detached result with inspection metadata.
The [component overview](../internal/overview.md) describes the current owners.
The audits identify failures at particular traversal, lifetime, error, and
accounting boundaries, alongside redundant representations and repeated work.
The target is to repair those boundaries and simplify their implementation while
preserving ConfigKit's public API and configuration model.
| Current gap | Targeted outcome |
| --- | --- |
| Recursive descriptors can fail to terminate, failed dynamic builds can leave partial state, and slice aliases can be mistaken for cycles. | Traversal terminates for the reported cases, failed construction leaves reusable state valid, and supported acyclic values remain accepted. |
| Callback and owned-root cleanup can be skipped during panic unwinding; directory errors lose their failure phase. | Cleanup follows the lifetime of each owned resource, and selected-directory failures retain the information needed for correct error handling. |
| Finite symlink dependencies can be expanded repeatedly. | Completed dependencies are reused within a resolution while active cycles remain detectable. |
| Projection reservations and published history can escape existing accounting. | Existing budgets govern the relevant allocations and complete retained document graph. |
| Collection-policy work repeatedly scans or copies unrelated state. | Policy preparation, application, and retained evidence have clear ownership, with work localized to affected policy. |
| Acquisition has redundant paths and difficult handoffs; several private representations have no production consumer. | Production paths and their tests share the active protocol, with cohesive handoffs and unused representations removed. |
| Some lookups repeat full scans, and some tests assert incidental representation or miss meaningful mutations. | Local indexing avoids the identified repeated work, and tests protect observable contracts at useful boundaries. |
This is a bounded remediation effort, not a redesign of configuration semantics.
Correcting erroneous acceptance, rejection, cleanup, or diagnostics in the
reported cases restores existing contracts. It does not select new loading APIs,
configuration options, limits, background work, or persistent caches.
## Selected Work
The following areas account for all 29 selected entries. Detailed examples and
acceptance cases remain with their linked audit group.
### 1. Safe Typed-Value Traversal And Descriptor Reuse
Selected entries: **A01, A02, A03, A04, A05** in
[schema and typed values](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#a-schema-and-typed-values).
Make pointer-only descriptor traversal terminate in decoding and path resolution,
while preserving supported null values and ordinary recursive structures. Make
dynamic descriptor construction transactional: a failed build must not leave
partial descriptors available to subsequent operations. Staging successful
additions or rolling back the complete failed addition set are implementation
choices; both must handle recursive references and preserve prior valid entries.
Correct active-path cycle detection to distinguish finite slice views sharing
storage from actual cycles. Keep detachment and nil/empty behavior intact across
the callers that share this traversal machinery.
Enforce existing projection budgets before large container reservations. Include
map-key materialization and overflow-safe lower-bound checks in the review; a
check after the reservation does not address the reported failure. This work
does not introduce a general heap limit or reject large containers independently
of existing budgets.
Dynamic-type eligibility reuse depends on safe descriptor construction and retains
completed successful checks within the value operation. Keep failure diagnostics
specific to their current path and distinguish completed results from traversal visits.
This removes repeated descriptor-graph walks without a cross-load cache.
### 2. Callback, Filesystem, And Dependency Lifetimes
Selected entries: **B01, B02** in [callback lifetime](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#b-callback-and-filesystem-lifetime)
and **C01, C02** in [filesystem traversal](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#c-import-selection-and-link-resolution).
Tie callback-context deactivation to the callback invocation with deferred cleanup
so it runs on normal return and panic unwinding. Independently give the owned
import root a deferred cleanup fallback. Preserve normal close-error handling,
caller ownership, and propagation of the original callback panic. Fixing either
state lifetime or handle lifetime does not fix the other.
Preserve the phase of a directory failure. A genuinely absent directory before
successful opening remains subject to existing permissiveness rules; a later
read or close failure must not be treated as absence. Apply this distinction to
both supported directory-reading paths. Strengthen the shared filesystem tests
to check each handle's closure and structured, non-disclosing diagnostics,
including partial reads and failures masked by another successful directory.
Do not change regular-file close-error policy as a side effect.
Avoid repeatedly expanding completed finite symlink dependencies in both host
and native filesystem resolution. Use operation-local memoization of completed
targets, or an equivalent dependency evaluation, with keys that respect the
resolution namespace. Keep active-path cycle detection separate from completed
work. Preserve containment, supported finite reuse, and existing host-path
semantics. The fix must work for directory and glob selection without a new
arbitrary traversal cap or changed symlink defaults.
### 3. Retained History And Combination Evidence
Selected entries: **D01, D02, D03, D04** in
[history and retained state](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#d-retained-history-and-unused-evidence).
Remove the unconsumed override-result pre-CLI copy and the unqueried published
structural-input copy. Preserve the separate pre-CLI state used by active replay
and the structural input still used before publication for metadata and secret
classification. Remove unused combination-evidence members and the work needed
solely to produce them, retaining projections, mappings, origins, and snapshots
that have active consumers.
With retained roots clarified, enforce the existing history allowance against
the complete graph published through inspection, including replay and inheritance
state and publication-created copies. Count shared nodes once by pointer identity;
distinct retained copies still consume the allowance. Account for required copies
before creating them where practical. Removing an unused tree alone does not
resolve the accounting omissions.
Keep required before/after evidence and existing inspection behavior. Validate
the complete publication path with small controlled allowances, including the
interaction of CLI, resolution replay, and inheritance, rather than relying only
on tests of an isolated counting helper.
### 4. Collection-Policy Preparation And Application
Selected entries: **E01, E02, E03** in
[collection policy and projection](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#e-collection-processing-and-efficiency).
Distinguish the existing policy state used for lookup, changes introduced by an
incoming tier, and evidence retained by the assembly. Prepare incoming claims and
declarations against read-only lower state, then apply the owned changes at the
appropriate combination boundary. Avoid copying every unrelated policy for
small tiers and scanning all policies for each independent replacement.
Use a semantic-path index, localized updates, or equivalent bounded lookup to
find affected policy. Preserve retirement timing, policy established by the
incoming contribution, pending declarations, persistent explicit modes, and
nested record ownership. Copying remains appropriate wherever mutable ownership
requires it. Removing only one duplicate clone or shortening a function does not
address the identified growth patterns.
Clarify preparation, evidence application, and finalization responsibilities.
Share straightforward history-descriptor translation where useful; evaluate
shared record-selection logic while keeping overlay and inheritance origin and
mode lifetimes distinct. Retain the common sequence-combination owner. This work
does not call for a generic merging framework.
### 5. Active Acquisition Handoffs And Private Cleanup
Selected entries: **F01, F02, F03, F04, F05, F06** in
[acquisition, cleanup, and test quality](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#f-remaining-maintenance-and-test-improvements).
Remove the test-only eager acquisition mode so acquisition retains scopes through
the active production protocol. Tests that need composition should invoke the
actual composition boundary explicitly. Retire the parallel test-only import
parser and route relevant fixtures through the active directive parser or the
appropriate declaration boundary, retaining malformed-input coverage.
Clarify active acquisition and coordinator handoffs around cohesive tasks:
parsed-input directive/version handling, standalone placement, and repeated tier
application/projection. Use explicit inputs and results consistent with existing
state owners. Extracting helpers that continue to share surrounding mutable state
does not establish a clearer handoff. Preserve ordinary/layered differences,
overlay and diff behavior, source identity, callback timing, diagnostics, and
accounting scopes.
Remove the disconnected root record-clone family, uncalled engine helpers, and
unread private transport/result members identified in the audit. Reconfirm
references on the implementation baseline, including function values and relevant
build configurations. Preserve live conversion paths and validation side effects;
the audit's absence of field reads is not a general rule for deleting state.
Replace meaningful ownership coverage through the public boundary before removing
tests that only exercise disconnected helpers.
### 6. Local Lookup Improvements
Selected entries: **E04, E05** in
[collection policy and projection](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#e-collection-processing-and-efficiency)
and **F08** in [secret-failure lookup](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#f08-index-secret-failure-location-queries-lazily).
Reuse a local document lookup or equivalent traversal for tier-history projection
instead of rescanning wide mappings for each sequence. Preserve authored
coordinates, sparse projections, and evidence ownership.
Restore compound identity fields using indexed mapping positions and membership
checks rather than rescanning child and merged mappings for every field. Preserve
child-authored values and locations, mapping order, and detached nodes.
Build secret-failure location lookup state lazily when failures need it. Preserve
all diagnostics, nearest-location fallbacks, and confidentiality; successful
loads should not incur an unnecessary index merely to support failure reporting.
These changes address specific repeated work and do not promise a measured
startup improvement.
### 7. Tests That Protect The Intended Behavior
Selected entries: **F09, F10, F11, F12** in
[acquisition, cleanup, and test quality](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#f-remaining-maintenance-and-test-improvements),
in addition to the regression coverage attached to each reported defect.
Cover historical-path enumeration through the public API, including removed
paths, queryability, ordering, and detached returned slices. Use those assertions
to replace private representation checks where they address the same risk.
Strengthen fuzz detachment checks by mutating existing descendants or in-length
elements, so spare capacity cannot hide aliases. Replace exact filesystem stat
call sequences with observable candidate ordering and meaningful failure
selection, retaining relevant assertions such as not opening excluded inputs.
Consolidate facade scaffolding around public construction, loading, errors,
ownership, and lifecycle behavior. Preserve distinct integration risks and
reader-byte snapshot coverage. Existing coverage percentages or a desired test
count are not acceptance criteria for this work.
## Implementation Plan
The historical implementation plan at `bb5540b:docs/roadmap/implementation.md`
defines the sequential, Luna-scoped stages and their dependencies, including
coordinated changes to descriptor state, history ownership/accounting, collection
policy, acquisition, and replacement public coverage. It also records validation
commands and the complete finding-to-stage map. The audit remains the owner of
original evidence and detailed acceptance cases.
## Validation And Completion
Validation follows the audit's detailed acceptance cases and the
[testing policy](../policy/testing.md). For fatal or hanging regressions, use bounded
subprocesses; for budget cases, use small controlled limits. For symlink expansion
and repeated lookup work, prefer bounded operation evidence and algorithm review
over timing thresholds. Use real filesystem fixtures where filesystem semantics
matter and narrow fault injection for read/close failures. State platform limits.
Applicable development checks come from the [development guide](../development.md),
including appropriate race coverage for changed ownership and lifetime boundaries.
The historical implementation plan owns their execution. Existing audit results
are baseline evidence, not validation of the eventual changes. No performance
benchmark campaign is required by this roadmap, and no new benchmark results are
claimed.
Completion means all 29 selected findings have implementation and acceptance
evidence, the affected public behavior remains consistent with its contracts,
and canonical internal documentation describes the resulting owners. The
historical implementation plan records execution status without duplicate
progress inventories. Retire completed planning records under the normal
documentation lifecycle.
## Scope Boundaries And Decision Status
All three audit scope questions are settled. No significant architectural or
product decision remains open for this roadmap. The historical implementation
plan records private design choices and sequencing, subject to the acceptance
cases above.
If that work exposes a need to change a public contract, bring the specific
decision back for review rather than treating it as an implicit part of remediation.
The glob preparation refactor (C03) and wider facade transport simplification
(F07) have since been selected in the
[internal-boundary roadmap](internal-boundaries.md). Other deferred features,
clone-traversal redesign, and broader performance experiments remain outside
this effort. Keep
existing ownership boundaries and loading APIs; no ADR or architecture-policy
change is proposed by this roadmap.

View File

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

View File

@@ -0,0 +1,45 @@
package consumers_test
import (
"fmt"
"os"
"path/filepath"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type workingDirectoryPresetConfig struct {
Name string `yaml:"name"`
}
func Example_workingDirectoryPreset() {
original, err := os.Getwd()
if err != nil {
fmt.Println(err)
return
}
defer func() { _ = os.Chdir(original) }()
root, err := os.MkdirTemp("", "configkit-working-search-")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(root)
if err := os.Chdir(root); err != nil {
fmt.Println(err)
return
}
if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil {
fmt.Println(err)
return
}
result, err := configkit.Load[workingDirectoryPresetConfig](
configkit.SearchConfig("service", configkit.WorkingDirectory()),
)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
// Output: example service.yml
}

View File

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

View File

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "gitea.maximumdirect.net/eric/ConfigKit"
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
func TestFacadeCompatibility(t *testing.T) { func TestFacadeCompatibility(t *testing.T) {
t.Parallel() t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []struct {
name string
type_ reflect.Type
}{
{"Path", reflect.TypeFor[configkit.Path]()},
{"Source", reflect.TypeFor[configkit.Source]()},
{"Secret", reflect.TypeFor[configkit.Secret]()},
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
} {
if candidate.type_.PkgPath() != packagePath {
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
}
}
constructed := configkit.Options[facadeCompatibilityConfig]{ constructed := configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{}, Definition: configkit.Definition[facadeCompatibilityConfig]{},
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"}, Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
@@ -56,11 +38,6 @@ func TestFacadeCompatibility(t *testing.T) {
if got := constructed.Overrides[0].Path.String(); got != "/value" { if got := constructed.Overrides[0].Path.String(); got != "/value" {
t.Fatalf("constructed override path = %q, want /value", got) t.Fatalf("constructed override path = %q, want /value", got)
} }
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
if publicResult.Config.Value != "constructed" {
t.Fatal("constructed result lost its public configuration")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{ result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{ Definition: configkit.Definition[facadeCompatibilityConfig]{
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error { Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {

316
facade_discovery_test.go Normal file
View File

@@ -0,0 +1,316 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
)
type facadeDiscoveryConfig struct {
Value string `yaml:"value"`
Settings map[string]string `yaml:"settings"`
}
type facadeDiscoveryLifecycleConfig struct {
Value string `yaml:"value"`
Resolved string `yaml:"resolved"`
}
type facadeDiscoveryPlacementConfig struct {
Items []facadeDiscoveryPlacementItem `yaml:"items"`
}
type facadeDiscoveryPlacementItem struct {
Name string `yaml:"name"`
}
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
original, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(original); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
writeFacadeImportFile(t, root, "myapp.yml", "value: working\n")
result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory()))
if err != nil || result.Config.Value != "working" {
t.Fatalf("working-directory search = %#v, %v", result, err)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) {
t.Fatalf("working-directory sources = %#v", sources)
}
}
func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) {
t.Parallel()
root := t.TempDir()
filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n")
custom := SearchDirectory(root, DirectorySearchOptions{
Filenames: []FilenameGroup{CustomFilenames("config.yml")},
})
result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom))
if err != nil || result.Config.Value != "found" {
t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) {
t.Fatalf("custom generic-only sources = %#v", sources)
}
defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} }
empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}})
absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{
Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults},
})
if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 {
t.Fatalf("explicit empty filename selection = %#v, %v", absent, err)
}
omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root)))
if err != nil || omitted.Config.Value != "found" {
t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err)
}
}
func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
t.Parallel()
root := t.TempDir()
selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n")
laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n")
paths := []string{filepath.Join(root, "missing.yml")}
names := []string{"config.yml"}
groups := []FilenameGroup{CustomFilenames(names...)}
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
source := SearchConfig("", search, SearchOptions{Required: true})
paths[0] = laterPath
names[0] = "later.yml"
groups[0] = CustomFilenames("later.yml")
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
search = Paths(laterPath)
result, err := Load[facadeDiscoveryConfig](source)
if err != nil || result.Config.Value != "selected" {
t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) {
t.Fatalf("ordered search sources = %#v", sources)
}
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
}
foundMissing := false
for _, diagnostic := range loadErr.Diagnostics() {
foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing
}
if !foundMissing {
t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics())
}
}
func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
t.Parallel()
root := t.TempDir()
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
result, err := LoadLayered(
Input{Source: SearchConfig("", Paths(mainPath))},
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
})),
}}}}},
)
if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) {
t.Fatalf("layered search = %#v, %v", result, err)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
t.Fatalf("layered search sources = %#v", sources)
}
}
func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
t.Parallel()
root := t.TempDir()
mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n")
writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n")
filesystem := &FilesystemContext{Root: root}
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
if err != nil || selected.Config.Value != "overlay" {
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
}
sources := selected.Sources()
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" ||
sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay {
t.Fatalf("selected search source evidence = %#v", sources)
}
mainContext, ok := selected.SourceContext("source-1")
if !ok || mainContext.BaseDir != "." {
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
}
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
searchedAbsent, err := Load(missing, options)
if err != nil {
t.Fatalf("exhausted search with root-only overlay context: %v", err)
}
explicitAbsent, err := Load(NoSource(), options)
if err != nil {
t.Fatalf("NoSource with root-only overlay context: %v", err)
}
if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) ||
!reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) {
t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent)
}
left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value"))
right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value"))
if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK {
t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK)
}
if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay {
t.Fatalf("absent search created unexpected source evidence = %#v", sources)
}
}
func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
t.Parallel()
root := t.TempDir()
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
outsideRoot := t.TempDir()
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
search := SearchConfig("", Paths(outside, inside))
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
var loadErr *LoadError
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) {
t.Fatalf("selected outside-root file error = %v, want containment failure", err)
}
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}})
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
t.Fatalf("selected file with explicit base error = %v, want option failure", err)
}
native := NewFilesystem("native", fstest.MapFS{})
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}})
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
t.Fatalf("selected file with native context error = %v, want option failure", err)
}
}
func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) {
t.Parallel()
root := t.TempDir()
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
Definition: Definition[facadeDiscoveryLifecycleConfig]{
Defaults: func() facadeDiscoveryLifecycleConfig {
defaultsCalls++
return facadeDiscoveryLifecycleConfig{}
},
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
resolveCalls++
configuration.Resolved = "from:" + configuration.Value
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
return nil
},
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
},
})
if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" {
t.Fatalf("search lifecycle result = %#v, %v", result, err)
}
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 {
t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls)
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" {
t.Fatalf("selected source evidence = %#v", sources)
}
valueInfo, found := result.Metadata().Lookup(Keys("value"))
if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID {
t.Fatalf("selected value metadata = %#v, %v", valueInfo, found)
}
var rendered strings.Builder
if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") {
t.Fatalf("effective output = %q, %v", rendered.String(), err)
}
}
func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
t.Parallel()
root := t.TempDir()
selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n")
options := Options[facadeDiscoveryPlacementConfig]{
Destination: Keys("items"),
PlacementMode: PlacementModeSequenceItem,
}
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
t.Fatalf("selected sequence item = %#v, %v", selected, err)
}
if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem {
t.Fatalf("selected sequence-item source = %#v", sources)
}
defaults := func() facadeDiscoveryPlacementConfig {
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
}
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
Destination: options.Destination,
PlacementMode: options.PlacementMode,
})
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
}
}
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
for _, diagnostic := range err.Diagnostics() {
if diagnostic.Code == code {
return true
}
}
return false
}
func canonicalFacadePath(t *testing.T, path string) string {
t.Helper()
absolute, err := filepath.Abs(path)
if err != nil {
t.Fatal(err)
}
canonical, err := filepath.EvalSymlinks(absolute)
if err != nil {
t.Fatal(err)
}
return canonical
}

View File

@@ -23,6 +23,9 @@ type equivalentConfig struct {
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) { func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n" const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
bytesData := []byte(yaml)
bytesSource := configkit.Bytes("memory", bytesData)
bytesData[0] = 'X'
hostPath := filepath.Join(t.TempDir(), "main.yml") hostPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil { if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
}{ }{
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile}, {"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem}, {"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes}, {"bytes", bytesSource, context, configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader}, {"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
{"mapping", configkit.Mapping("programmatic", map[string]any{ {"mapping", configkit.Mapping("programmatic", map[string]any{
"version": uint64(1), "name": "retained", "enabled": false, "count": 0, "version": uint64(1), "name": "retained", "enabled": false, "count": 0,
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed { if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("reader re-consumption = %s", got) t.Fatalf("reader re-consumption = %s", got)
} }
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
readerCopy := copiedReader
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
t.Fatal(err)
}
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("copied reader re-consumption = %s", got)
}
} }
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) { func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {

View File

@@ -0,0 +1,62 @@
package configkit_test
import (
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte(
"retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n",
)), configkit.Options[map[string]any]{Overrides: []configkit.Override{
{Path: configkit.Keys("retired"), Value: nil, Flag: "--retired"},
{Path: configkit.Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
}})
if err != nil {
t.Fatalf("Load: %v", err)
}
metadata := loaded.Metadata()
paths := metadata.HistoricalPaths()
got := make([]string, len(paths))
for index, path := range paths {
got[index] = path.String()
if len(metadata.History(path)) == 0 {
t.Errorf("enumerated path %s has no history", path)
}
}
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("historical paths = %v, want sorted unique pointers %v", got, want)
}
if _, ok := metadata.Lookup(paths[4]); ok {
t.Fatal("removed descendant remains effective")
}
if _, ok := metadata.Lookup(paths[6]); !ok {
t.Fatal("enumerated numeric path did not follow the effective sequence shape")
}
if len(metadata.History(paths[6])) == 0 {
t.Fatal("enumerated numeric path lost history")
}
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
t.Fatal("mapping-key path resolved against the effective sequence")
}
if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
t.Fatal("zero metadata contains historical paths")
}
paths[1] = configkit.Keys("mutated")
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
t.Fatalf("returned path slice mutated retained metadata: %v", pathStrings(again))
}
}
func pathStrings(paths []configkit.Path) []string {
result := make([]string, len(paths))
for index, path := range paths {
result[index] = path.String()
}
return result
}

View File

@@ -6,8 +6,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
) )
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) { func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
@@ -125,20 +123,61 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 { if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
t.Fatalf("required empty selection was recorded as optional: %#v", sources) t.Fatalf("required empty selection was recorded as optional: %#v", sources)
} }
}
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit: func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
type config struct {
Version string `yaml:"version"`
Imported string `yaml:"imported"`
}
for _, layered := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
configkit:
imports: imports:
- dir: recursive-files - dir: parts
`) `)
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n") writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
recursive := true
patterns := []string{"*.cfg"} recursive := true
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}}) patterns := []string{"*.cfg"}
recursive = false definition := Definition[config]{Version: &VersionRule{
patterns[0] = "[" Path: Keys("version"),
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions) Check: func(version Version, _ *Validation) error {
if err != nil || loadedEngine.Config.Value != "selected" { if version != (Version{Present: true, Text: "v1"}) {
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err) t.Errorf("version callback received %#v", version)
}
recursive = false
patterns[0] = "*.skip"
return nil
},
}}
load := func() (*Result[config], error) {
defaults := ImportDefaults{Recursive: &recursive, Pattern: patterns}
if layered {
return LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, ImportDefaults: defaults,
})
}
return Load(File(mainPath), Options[config]{
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, Imports: true, ImportDefaults: defaults,
})
}
first, err := load()
if err != nil || first.Config.Imported != "selected" {
t.Fatalf("load with pre-callback defaults = %#v, %v", first, err)
}
if recursive || patterns[0] != "*.skip" {
t.Fatalf("version callback did not mutate caller defaults: recursive=%v pattern=%q", recursive, patterns[0])
}
second, err := load()
if err != nil || second.Config.Imported != "" {
t.Fatalf("subsequent load reused earlier defaults: %#v, %v", second, err)
}
})
} }
} }

View File

@@ -1,52 +0,0 @@
package configkit
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
data := []byte("value: retained\n")
source := Bytes("snapshot.yml", data)
data[7] = 'X'
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
if err != nil || result.Config.Value != "retained" {
t.Fatalf("snapshotted source = %#v, %v", result, err)
}
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
first := readerSource
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
t.Fatal(err)
}
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
var loadError *engine.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
t.Fatalf("copied reader source error = %#v", err)
}
}
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
t.Parallel()
parsed, err := ParsePath("/0")
if err != nil {
t.Fatal(err)
}
keyed, indexed := Keys("0"), Path{}.Index(0)
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
}
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
}
}

View File

@@ -0,0 +1,69 @@
package configkit_test
import (
"fmt"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
t.Parallel()
for _, layered := range []bool{false, true} {
for _, dynamic := range []bool{false, true} {
for _, missingOuterID := range []bool{false, true} {
t.Run(fmt.Sprintf("layered=%t/dynamic=%t/missing-outer-id=%t", layered, dynamic, missingOuterID), func(t *testing.T) {
payload := "prompts: [{text: helper}]\n"
unused := configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")
if dynamic {
payload = "catalog: {local: {prompts: [{text: helper}]}}\n"
unused = configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
}, "id")
}
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
if !missingOuterID {
item += "id: outer\n"
}
files := configkit.NewFilesystem("items", fstest.MapFS{
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
"child-helper.yml": {Data: []byte(payload)},
})
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
unused, configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
}}
var result *configkit.Result[map[string]any]
var err error
if layered {
result, err = configkit.LoadLayered(configkit.Input{Source: files.File("main.yml"), Imports: true}, configkit.LayeredOptions[map[string]any]{Definition: definition})
} else {
result, err = configkit.Load(files.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
}
if missingOuterID {
failure := requireFacadeLoadError(t, err)
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeComposeInvalid || failure.Diagnostics()[0].Path.String() != "/items/0/id" {
t.Fatalf("outer constraint diagnostics = %#v", failure.Diagnostics())
}
return
}
if err != nil {
t.Fatal(err)
}
for _, record := range []configkit.Path{configkit.Keys("items").Index(0), configkit.Keys("items").Index(0).Key("children").Index(0)} {
path := record
if dynamic {
path = path.Key("catalog").Key("local")
}
info, found := result.Metadata().Lookup(path.Key("prompts").Index(0).Key("text"))
if !found || !info.Explicit {
t.Fatalf("nested helper payload lost at %s", path)
}
}
})
}
}
}
}

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.26.0
require go.yaml.in/yaml/v4 v4.0.0-rc.6 require go.yaml.in/yaml/v4 v4.0.0-rc.6
require ( require (
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
github.com/yuin/goldmark/v2 v2.1.6 // indirect github.com/yuin/goldmark/v2 v2.1.6 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/mod v0.40.0 // indirect
) )

4
go.sum
View File

@@ -1,5 +1,5 @@
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI= gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI= gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE= github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ= github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=

View File

@@ -0,0 +1,293 @@
package engine
import "strconv"
type acquisitionPolicy struct {
importDefaults ImportDefaults
sequencePolicy sequencePolicy
importLimits importLimits
}
type acquisitionState struct {
accounting *importAccounting
versionState *sourceVersionState
}
type tierAcquisitionState struct {
visited map[resourceIdentity]importRoute
hostToken *namespaceToken
}
type inputAcquisitionRequest struct {
input loadInput
role sourceVersionRole
directiveContext rootDirectiveContext
protectionRule *VersionRule
ordinaryOverlay string
}
type importAcquisitionRequest struct {
input inputAcquisitionRequest
main parsedSource
processing *sourceVersionProcessingResult
}
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
source := request.input.source
destination := request.input.destination
accounting := state.accounting
versionState := state.versionState
sourceID := "source-" + strconv.Itoa(accounting.sources+1)
selectedSource := source
if source.kind == sourceSearch {
resolved, failedSource, diagnostics := resolveSearchInput(source, sourceID, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe())
if len(diagnostics) != 0 {
if failedSource != nil {
failedSource.Destination = destination.clone()
failedSource.placementMode = request.input.placementMode
}
return assembledInput{source: resolved, sources: sourceInfos(failedSource), versionState: versionState, accounting: accounting}, diagnostics
}
selectedSource = resolved
if request.input.filesystem != nil {
if diagnostics := filesystemOptionDiagnostics(selectedSource, request.input.filesystem); len(diagnostics) != 0 {
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
}
}
if selectedSource.kind == sourceNone && activeOverlay(request.ordinaryOverlay) && request.input.filesystem == nil {
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
}
}
limits := policy.importLimits
parseLimits := defaultParseLimits()
parseLimits.source.remaining = limits.bytes - accounting.bytes
parsed, diagnostics := parseSource(selectedSource, sourceID, parseLimits)
if parsed.source != nil {
parsed.source.Destination = destination.clone()
parsed.source.placementMode = request.input.placementMode
}
input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
if len(diagnostics) != 0 {
input.sources = sourceInfos(parsed.source)
// Mapping value issues already identify application content. Other early
// failures describe source syntax or access, not a placed value.
if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 {
for index := range diagnostics {
relative := diagnostics[index].Path
segments := clonePathSegments(destination.segments, len(relative.segments))
diagnostics[index].Path = Path{segments: append(segments, relative.segments...)}
}
}
return input, diagnostics
}
directiveContext := request.directiveContext
if directiveContext == 0 {
directiveContext = rootDirectiveContextImportsOnly
}
if !request.input.imports && parsed.document != nil {
var directiveDiagnostics []Diagnostic
if directiveContext == rootDirectiveContextSource || directiveContext == rootDirectiveContextLayered {
directiveDiagnostics = disabledRootImportDiagnostics(parsed.document)
} else {
directiveDiagnostics = disabledDirectiveDiagnostics(parsed.document, Path{})
}
if len(directiveDiagnostics) != 0 {
return input, directiveDiagnostics
}
}
request.directiveContext = directiveContext
versionProcessing, diagnostics := processAcquiredInputVersion(parsed, request, versionState)
if len(diagnostics) != 0 {
return input, diagnostics
}
request.input.source = selectedSource
if request.input.imports && selectedSource.kind != sourceNone {
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
if len(diagnostics) != 0 {
return input, diagnostics
}
if input.composition != nil {
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
input.document = input.composition.root
}
} else {
input, diagnostics = acquireStandaloneInput(input, request, versionProcessing, policy, tier)
if len(diagnostics) != 0 {
return input, diagnostics
}
}
return completeAcquisitionContexts(input, request, tier)
}
func acquireStandaloneInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
parsed := input.parsed
input.sources = sourceInfos(parsed.source)
if processing != nil {
input.document = processing.payload
}
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
}
if request.input.imports || input.document == nil {
return input, nil
}
limits := policy.importLimits
placement := request.input.destination
documentLimits := defaultParseLimits().document
_, placed, err := placeDocumentWithMode(input.document, placement, request.input.placementMode, input.document.Location, documentLimits.Depth, documentLimits.Nodes)
if err != nil {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
}
localPayload := input.document
input.document = placed
identity := importKeyForMainSource(parsed.source)
var sequenceDeclarations []sequenceDeclarationSpec
if processing != nil {
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
}
input.acquisition = []acquisitionScope{{
owner: identity,
destination: placement.clone(),
contributions: []acquiredContribution{{
source: cloneSourceInfo(*parsed.source), payload: localPayload,
identity: identity, role: importRoleMain, scopeOwner: identity,
document: placed, destination: placement.clone(), processing: processing,
sequenceDeclarations: sequenceDeclarations,
}},
}}
rawNodes := countInputNodes(parsed.document)
placementNodes := destinationWrapperNodeCountForMode(placement, request.input.placementMode)
accounting := input.accounting
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
}
if diagnostic := registerAcquiredSource(input.source, request.input.filesystem, parsed.source, tier.visited, tier.hostToken); diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
accounting.sources++
accounting.bytes += parsed.bytes
accounting.nodes += rawNodes + placementNodes
return input, nil
}
func completeAcquisitionContexts(input assembledInput, request inputAcquisitionRequest, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
if activeOverlay(request.ordinaryOverlay) && !request.input.imports {
return input, nil
}
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (input.source.kind == sourceFile || request.input.filesystem != nil || nativeContext(input.source, request.input.filesystem)) {
resolved, diagnostic := resolveFilesystemContext(input.source, request.input.filesystem, input.parsed, tier.hostToken)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
}
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.contexts = contexts
return input, nil
}
func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRequest, state *sourceVersionState) (*sourceVersionProcessingResult, []Diagnostic) {
sourceID := ""
if parsed.source != nil {
sourceID = parsed.source.ID
}
var result sourceVersionProcessingResult
hasResult := false
updateMainProcessing := false
switch request.directiveContext {
case rootDirectiveContextSource:
if state != nil {
processed, diagnostics := state.processWithDirectiveContext(
parsed.document, request.role, request.input.destination, sourceID,
request.input.placementMode, request.directiveContext,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
result = processed
updateMainProcessing = request.role == sourceVersionMain
} else {
result = processSourceVersionWithDirectiveContext(
parsed.document, request.protectionRule, request.role,
request.input.destination, sourceVersionModeDefault, request.directiveContext,
)
if len(result.diagnostics) != 0 {
return nil, result.diagnostics
}
}
hasResult = true
case rootDirectiveContextLayered:
extracted, diagnostics := extractRootDirectives(parsed.document, request.directiveContext)
if len(diagnostics) != 0 {
return nil, diagnostics
}
result = sourceVersionProcessingResult{
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
directiveContext: request.directiveContext,
}
hasResult = true
if state != nil && state.mode != sourceVersionModeDefault {
processed, diagnostics := state.process(
extracted.body, request.role, request.input.destination,
sourceID, request.input.placementMode,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
processed.imports = extracted.imports
processed.sequences = append(cloneSequenceDeclarations(extracted.sequences), processed.sequences...)
processed.directiveContext = request.directiveContext
result = processed
updateMainProcessing = request.role == sourceVersionMain
}
default:
if state != nil && state.mode != sourceVersionModeDefault {
processed, diagnostics := state.process(
parsed.document, request.role, request.input.destination,
sourceID, request.input.placementMode,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
result = processed
hasResult = true
updateMainProcessing = request.role == sourceVersionMain
}
}
if !hasResult {
return nil, nil
}
if updateMainProcessing {
completed := result
state.mainProcessing = &completed
}
return &result, nil
}
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
if visited == nil || info == nil || info.Path == "" {
return nil
}
identity := resourceIdentity{namespace: hostToken, path: info.Path}
if source.kind == sourceFilesystem {
identity.namespace = source.namespace.token
} else if context != nil && context.FS != nil {
identity.namespace = context.FS.namespace.token
}
if previous, repeated := visited[identity]; repeated {
value := importRepeatedDiagnostic(Location{SourceID: info.ID}, previous, importRoute{})
return &value
}
visited[identity] = importRoute{canonical: info.Path}
return nil
}

View File

@@ -0,0 +1,73 @@
package engine
import (
"errors"
"testing"
)
func capturePanic(call func()) (recovered any) {
defer func() { recovered = recover() }()
call()
return nil
}
func requirePanicValue(t *testing.T, want any, call func()) {
t.Helper()
if got := capturePanic(call); got != want {
t.Fatalf("panic = %#v, want %#v", got, want)
}
}
func TestCallbackContextsCloseWhenCallbacksPanic(t *testing.T) {
type config struct {
Value string `yaml:"value"`
Version string `yaml:"version"`
}
t.Run("resolution", func(t *testing.T) {
sentinel := errors.New("resolution panic sentinel")
var retained *Resolution
got := capturePanic(func() {
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
panic(sentinel)
}}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.IsExplicit(Keys("value")) })
})
t.Run("validation", func(t *testing.T) {
sentinel := errors.New("validation panic sentinel")
var retained *Validation
got := capturePanic(func() {
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) {
retained = validation
panic(sentinel)
}}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Error(Keys("value"), "too late") })
})
t.Run("version", func(t *testing.T) {
sentinel := errors.New("version panic sentinel")
var retained *Validation
got := capturePanic(func() {
_, _ = testLoad[config](Bytes("version.yml", []byte("version: v1\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
panic(sentinel)
}},
}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Warning(Keys("version"), "too late") })
})
}

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

View File

@@ -14,11 +14,7 @@ type collectionPolicy struct {
type collectionPolicyResolution struct { type collectionPolicyResolution struct {
policy collectionPolicy policy collectionPolicy
operation sequenceMode operation sequenceMode
declarations []sequenceDeclarationSpec
declarationsPresent bool
identityDeclarationPresent bool
modeDeclarationPresent bool modeDeclarationPresent bool
applicationIdentityFields []string
applicationIdentityPresent bool applicationIdentityPresent bool
effectiveIdentityFields []string effectiveIdentityFields []string
} }
@@ -60,9 +56,6 @@ func resolveCollectionPolicy(
} }
} }
result := collectionPolicyResolution{ result := collectionPolicyResolution{
declarations: normalizedDeclarations,
declarationsPresent: len(normalizedDeclarations) != 0,
applicationIdentityFields: normalizedApplicationIdentityFields,
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0, applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
} }
@@ -73,7 +66,6 @@ func resolveCollectionPolicy(
var declaredModeLocation *Location var declaredModeLocation *Location
for _, declaration := range normalizedDeclarations { for _, declaration := range normalizedDeclarations {
if declaration.identityPresent { if declaration.identityPresent {
result.identityDeclarationPresent = true
if declaredIdentityFields == nil { if declaredIdentityFields == nil {
declaredIdentityFields = cloneStrings(declaration.identityFields) declaredIdentityFields = cloneStrings(declaration.identityFields)
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location) declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)

View File

@@ -0,0 +1,137 @@
package engine
// collectionPolicyRetirementIndex locates only assembly policies whose YAML
// identity or record-owned state can be cleared by an ancestor replacement.
// Mapping keys and sequence indexes remain distinct through pathSegment keys.
type collectionPolicyRetirementIndex struct {
children map[pathSegment]*collectionPolicyRetirementIndex
entryKey string
hasEntry bool
}
func newCollectionPolicyRetirementIndex(scope collectionPolicyScope) *collectionPolicyRetirementIndex {
index := &collectionPolicyRetirementIndex{}
for _, entry := range scope.collections {
if collectionPolicyHasRetirementState(entry.policy) {
index.set(entry)
}
}
return index
}
func (index *collectionPolicyRetirementIndex) set(entry collectionPolicyEntry) {
node := index
for _, segment := range entry.destination.segments {
if node.children == nil {
node.children = make(map[pathSegment]*collectionPolicyRetirementIndex)
}
if node.children[segment] == nil {
node.children[segment] = &collectionPolicyRetirementIndex{}
}
node = node.children[segment]
}
node.entryKey = entry.destination.String()
node.hasEntry = true
}
func (index *collectionPolicyRetirementIndex) remove(destination Path) {
if index == nil {
return
}
nodes := make([]*collectionPolicyRetirementIndex, 1, len(destination.segments)+1)
nodes[0] = index
node := index
for _, segment := range destination.segments {
node = node.children[segment]
if node == nil {
return
}
nodes = append(nodes, node)
}
node.entryKey = ""
node.hasEntry = false
pruneCollectionPolicyIndex(nodes, destination.segments)
}
func (index *collectionPolicyRetirementIndex) retire(scope *collectionPolicyScope, ancestor Path) {
if index == nil || scope == nil {
return
}
nodes := make([]*collectionPolicyRetirementIndex, 1, len(ancestor.segments)+1)
nodes[0] = index
node := index
for _, segment := range ancestor.segments {
node = node.children[segment]
if node == nil {
return
}
nodes = append(nodes, node)
}
index.clearSubtree(scope, node)
pruneCollectionPolicyIndex(nodes, ancestor.segments)
}
func (index *collectionPolicyRetirementIndex) clearSubtree(scope *collectionPolicyScope, node *collectionPolicyRetirementIndex) {
if node.hasEntry {
if entry, found := scope.collections[node.entryKey]; found {
clearCollectionIdentity(&entry.policy)
scope.collections[node.entryKey] = entry
}
node.entryKey = ""
node.hasEntry = false
}
for segment, child := range node.children {
index.clearSubtree(scope, child)
if child.empty() {
delete(node.children, segment)
}
}
if len(node.children) == 0 {
node.children = nil
}
}
func (index *collectionPolicyRetirementIndex) empty() bool {
return index == nil || !index.hasEntry && len(index.children) == 0
}
func pruneCollectionPolicyIndex(nodes []*collectionPolicyRetirementIndex, segments []pathSegment) {
for position := len(segments) - 1; position >= 0; position-- {
child := nodes[position+1]
if !child.empty() {
return
}
delete(nodes[position].children, segments[position])
}
}
func collectionPolicyHasRetirementState(policy collectionPolicy) bool {
return policy.identityFields != nil || policy.identityPresent || policy.identityLocation != nil ||
policy.recordOrder != nil || policy.recordPolicies != nil
}
func (assembly *assemblyAccumulator) setCollectionPolicy(destination Path, policy collectionPolicy) {
if assembly == nil || assembly.current == nil {
return
}
if assembly.collectionPolicyRetirement == nil {
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
}
assembly.current.collectionPolicies.set(destination, policy)
entry := assembly.current.collectionPolicies.collections[destination.String()]
if collectionPolicyHasRetirementState(entry.policy) {
assembly.collectionPolicyRetirement.set(entry)
} else {
assembly.collectionPolicyRetirement.remove(destination)
}
}
func (assembly *assemblyAccumulator) retireCollectionPoliciesAtOrBelow(ancestor Path) {
if assembly == nil || assembly.current == nil {
return
}
if assembly.collectionPolicyRetirement == nil {
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
}
assembly.collectionPolicyRetirement.retire(&assembly.current.collectionPolicies, ancestor)
}

View File

@@ -21,8 +21,8 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("policy diagnostics = %#v", diagnostics) t.Fatalf("policy diagnostics = %#v", diagnostics)
} }
if !resolved.declarationsPresent || !resolved.identityDeclarationPresent || !resolved.modeDeclarationPresent { if !resolved.modeDeclarationPresent {
t.Fatalf("declaration presence was lost: %#v", resolved) t.Fatalf("mode declaration was lost: %#v", resolved)
} }
wantFields := []string{"id", "namespace"} wantFields := []string{"id", "namespace"}
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) { if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
@@ -31,9 +31,6 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend { if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation) t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
} }
if len(resolved.declarations) != 3 || resolved.declarations[0].identityLocation == nil || resolved.declarations[2].location.Line == 0 {
t.Fatalf("declaration locations were not retained: %#v", resolved.declarations)
}
} }
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) { func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {

View File

@@ -7,6 +7,23 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// operation at that exact path. // operation at that exact path.
type collectionReplacementPaths map[string]bool type collectionReplacementPaths map[string]bool
type lowerCollectionPolicyView struct {
scope collectionPolicyScope
replacements collectionReplacementPaths
}
func (view lowerCollectionPolicyView) at(path Path) (collectionPolicy, bool) {
entry, found := view.scope.collections[path.String()]
if !found {
return collectionPolicy{}, false
}
policy := entry.policy
if view.replacements.contains(path, true) {
clearCollectionIdentity(&policy)
}
return policy, true
}
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool { func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
for length := 0; length <= len(path.segments); length++ { for length := 0; length <= len(path.segments); length++ {
prefix := Path{segments: path.segments[:length]} prefix := Path{segments: path.segments[:length]}
@@ -27,6 +44,26 @@ func lowerCollectionPoliciesForClaims(
declarations map[string][]sequenceDeclarationSpec, declarations map[string][]sequenceDeclarationSpec,
application sequencePolicy, application sequencePolicy,
) (collectionPolicyScope, collectionReplacementPaths) { ) (collectionPolicyScope, collectionReplacementPaths) {
replaced := collectionReplacementPathsForClaims(lower, claims, declarations, application)
// This materialization is used when inheritance creates an independently
// owned local scope. Strict tier resolution uses lowerCollectionPolicyView
// instead and keeps only its sparse current-scope result.
result := cloneCollectionPolicyScope(lower)
for key, entry := range result.collections {
if replaced.contains(entry.destination, true) {
clearCollectionIdentity(&entry.policy)
result.collections[key] = entry
}
}
return result, replaced
}
func collectionReplacementPathsForClaims(
lower collectionPolicyScope,
claims []compositionClaim,
declarations map[string][]sequenceDeclarationSpec,
application sequencePolicy,
) collectionReplacementPaths {
byPath := make(map[string]compositionClaim, len(claims)) byPath := make(map[string]compositionClaim, len(claims))
rootPath := "" rootPath := ""
if len(claims) != 0 { if len(claims) != 0 {
@@ -62,14 +99,7 @@ func lowerCollectionPoliciesForClaims(
replaced[key] = false replaced[key] = false
} }
} }
result := cloneCollectionPolicyScope(lower) return replaced
for key, entry := range result.collections {
if replaced.contains(entry.destination, true) {
clearCollectionIdentity(&entry.policy)
result.collections[key] = entry
}
}
return result, replaced
} }
func clearCollectionIdentity(policy *collectionPolicy) { func clearCollectionIdentity(policy *collectionPolicy) {

View File

@@ -2,7 +2,10 @@ package engine
import ( import (
"errors" "errors"
"fmt"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) { func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
@@ -109,3 +112,239 @@ func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *
} }
} }
} }
func TestAssemblyPolicyRetirementUsesSemanticPathSegments(t *testing.T) {
t.Parallel()
var policies collectionPolicyScope
slashPath := Keys("branch/name", "items")
separateKeysPath := Keys("branch", "name", "items")
numericKeyPath := Keys("items", "0", "named")
sequenceIndexPath := Keys("items").Index(0).Key("indexed")
policies.set(slashPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(separateKeysPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(numericKeyPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(sequenceIndexPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
assembly.retireCollectionPoliciesAtOrBelow(Keys("branch/name"))
assertRetirement := func(path Path, identityPresent bool) {
t.Helper()
policy, found := assembly.current.collectionPolicies.at(path)
if !found || policy.identityPresent != identityPresent {
t.Fatalf("policy at %s = %#v (found %t), want identity present %t", path, policy, found, identityPresent)
}
}
assertRetirement(slashPath, false)
assertRetirement(separateKeysPath, true)
assembly.retireCollectionPoliciesAtOrBelow(Keys("items", "0"))
assertRetirement(numericKeyPath, false)
assertRetirement(sequenceIndexPath, true)
assembly.retireCollectionPoliciesAtOrBelow(Keys("items").Index(0))
assertRetirement(sequenceIndexPath, false)
}
func TestAssemblyPolicyRetirementPrunesAndReindexesAroundModeOnlyEntries(t *testing.T) {
t.Parallel()
identityPath := Keys("catalog", "items")
modePath := Keys("catalog", "settings")
var policies collectionPolicyScope
policies.set(identityPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(modePath, collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
retired, found := assembly.current.collectionPolicies.at(identityPath)
if !found || retired.identityPresent || !assembly.collectionPolicyRetirement.empty() {
t.Fatalf("first retirement left identity or stale index: %#v, index %#v", retired, assembly.collectionPolicyRetirement)
}
mode, found := assembly.current.collectionPolicies.at(modePath)
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
t.Fatalf("mode-only policy changed during retirement: %#v, found %t", mode, found)
}
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
newPath := Keys("catalog", "items", "nested")
assembly.setCollectionPolicy(newPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
if assembly.collectionPolicyRetirement.empty() {
t.Fatal("new identity-bearing policy was not reindexed")
}
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
newPolicy, found := assembly.current.collectionPolicies.at(newPath)
if !found || newPolicy.identityPresent || !assembly.collectionPolicyRetirement.empty() {
t.Fatalf("repeated ancestor retirement left new identity or stale index: %#v, index %#v", newPolicy, assembly.collectionPolicyRetirement)
}
mode, found = assembly.current.collectionPolicies.at(modePath)
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
t.Fatalf("repeated retirement changed retained mode: %#v, found %t", mode, found)
}
}
func TestDisjointScalarReplacementsRetainIndexedCollectionPolicies(t *testing.T) {
t.Parallel()
const policyCount = 128
const replacementCount = 32
var policies collectionPolicyScope
collectionEntries := make([]document.Entry, 0, policyCount)
for index := 0; index < policyCount; index++ {
key := fmt.Sprintf("collection-%03d", index)
path := Keys("collections", key)
policies.set(path, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
collectionEntries = append(collectionEntries, document.Entry{
Key: key, Value: &document.Node{Kind: document.Sequence},
})
}
baseScalars := make([]document.Entry, 0, replacementCount)
incomingScalars := make([]document.Entry, 0, replacementCount)
for index := 0; index < replacementCount; index++ {
key := fmt.Sprintf("scalar-%03d", index)
baseScalars = append(baseScalars, document.Entry{
Key: key, Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "previous", Value: &document.Node{Kind: document.Boolean, Text: "true"},
}}},
})
incomingScalars = append(incomingScalars, document.Entry{
Key: key, Value: &document.Node{Kind: document.String, Text: "replaced"},
})
}
base := &composedDocument{
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "collections", Value: &document.Node{Kind: document.Mapping, Mapping: collectionEntries}},
{Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: baseScalars}},
}},
collectionPolicies: policies,
origins: make(map[string]Origin),
}
incoming := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: incomingScalars},
}}}
assembly := newAssemblyAccumulator(base)
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
incoming, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, nil,
); len(diagnostics) != 0 {
t.Fatalf("apply disjoint scalar replacements: %#v", diagnostics)
}
for index := 0; index < policyCount; index++ {
path := Keys("collections", fmt.Sprintf("collection-%03d", index))
policy, found := assembly.current.collectionPolicies.at(path)
if !found || !policy.identityPresent || len(policy.identityFields) != 1 || policy.identityFields[0] != "id" {
t.Fatalf("disjoint replacement changed policy %s: %#v, found %t", path, policy, found)
}
}
for index := 0; index < replacementCount; index++ {
path := Keys("scalars", fmt.Sprintf("scalar-%03d", index))
value, found := documentAtPath(assembly.current.root, path)
if !found || value.Kind != document.String || value.Text != "replaced" {
t.Fatalf("scalar replacement at %s = %#v, found %t", path, value, found)
}
}
}
func TestDisjointTierOverlaysRetainNestedCollectionPolicies(t *testing.T) {
t.Parallel()
nestedOwner := newCollectionRecordPolicy()
nestedOwner.scope.set(Keys("prompts"), collectionPolicy{
identityFields: []string{"name"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
itemsPolicy := collectionPolicyWithRecords(collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
}, nestedOwner)
var policies collectionPolicyScope
policies.set(Keys("items"), itemsPolicy)
policies.set(Keys("keyOnly"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
})
policies.set(Keys("modeOnly"), collectionPolicy{
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
base := &composedDocument{
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "items", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
Kind: document.Mapping,
Mapping: []document.Entry{
{Key: "id", Value: &document.Node{Kind: document.String, Text: "one"}},
{Key: "prompts", Value: &document.Node{Kind: document.Sequence}},
},
}}}},
{Key: "keyOnly", Value: &document.Node{Kind: document.Sequence}},
{Key: "modeOnly", Value: &document.Node{Kind: document.Sequence}},
}},
collectionPolicies: policies,
origins: make(map[string]Origin),
}
assembly := newAssemblyAccumulator(base)
initialItems, found := assembly.current.collectionPolicies.at(Keys("items"))
if !found {
t.Fatal("initial item policy is missing")
}
initialRecord, found := initialItems.recordPolicyAt(0)
if !found {
t.Fatal("initial nested record owner is missing")
}
for index, key := range []string{"firstTier", "secondTier", "thirdTier"} {
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: key, Value: &document.Node{Kind: document.Boolean, Text: "true"},
}}}
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
); len(diagnostics) != 0 {
t.Fatalf("apply disjoint tier %d: %#v", index, diagnostics)
}
retainedItems, found := assembly.current.collectionPolicies.at(Keys("items"))
if !found || !retainedItems.identityPresent || len(retainedItems.identityFields) != 1 || retainedItems.identityFields[0] != "id" ||
!retainedItems.explicitModePresent || retainedItems.explicitMode != sequenceModeAppend {
t.Fatalf("tier %d changed retained item policy: %#v, found %t", index, retainedItems, found)
}
retainedRecord, found := retainedItems.recordPolicyAt(0)
if !found || retainedRecord.owner != initialRecord.owner {
t.Fatalf("tier %d replaced the nested record owner: %#v, found %t", index, retainedRecord, found)
}
nested, found := retainedRecord.scope.at(Keys("prompts"))
if !found || !nested.identityPresent || len(nested.identityFields) != 1 || nested.identityFields[0] != "name" ||
!nested.explicitModePresent || nested.explicitMode != sequenceModeAppend {
t.Fatalf("tier %d changed nested collection policy: %#v, found %t", index, nested, found)
}
keyOnly, found := assembly.current.collectionPolicies.at(Keys("keyOnly"))
if !found || !keyOnly.identityPresent || keyOnly.explicitModePresent {
t.Fatalf("tier %d changed key-only policy: %#v, found %t", index, keyOnly, found)
}
modeOnly, found := assembly.current.collectionPolicies.at(Keys("modeOnly"))
if !found || modeOnly.identityPresent || !modeOnly.explicitModePresent || modeOnly.explicitMode != sequenceModeAppend {
t.Fatalf("tier %d changed mode-only policy: %#v, found %t", index, modeOnly, found)
}
}
}
func TestOverlayNonSequenceClaimAtRetainedCollectionStillFails(t *testing.T) {
t.Parallel()
var policies collectionPolicyScope
policies.set(Keys("items"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
base := &composedDocument{
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "items", Value: &document.Node{Kind: document.Sequence},
}}},
collectionPolicies: policies,
origins: make(map[string]Origin),
}
assembly := newAssemblyAccumulator(base)
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "items", Value: &document.Node{Kind: document.String, Text: "not a sequence"},
}}}
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
); len(diagnostics) == 0 {
t.Fatal("non-sequence overlay at an append collection unexpectedly succeeded")
}
}

View File

@@ -1,6 +1,10 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import (
"maps"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// composedDocument is the private handoff between source assembly and later // composedDocument is the private handoff between source assembly and later
// precedence layers. Claims remain ordered by source and declaration traversal; // precedence layers. Claims remain ordered by source and declaration traversal;
@@ -39,12 +43,20 @@ type sequenceHistoryContribution struct {
// assemblyAccumulator owns one mutable working document. Acquired trees held by // assemblyAccumulator owns one mutable working document. Acquired trees held by
// history applications are separate from its root and remain unchanged. // history applications are separate from its root and remain unchanged.
type assemblyAccumulator struct { type assemblyAccumulator struct {
current *composedDocument current *composedDocument
index mappingIndex index mappingIndex
collectionPolicyRetirement *collectionPolicyRetirementIndex
} }
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator { func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
return &assemblyAccumulator{current: owned} policies := collectionPolicyScope{}
if owned != nil {
policies = owned.collectionPolicies
}
return &assemblyAccumulator{
current: owned,
collectionPolicyRetirement: newCollectionPolicyRetirementIndex(policies),
}
} }
// cloneComposedDocument detaches mutable composition and provenance state; // cloneComposedDocument detaches mutable composition and provenance state;
@@ -89,19 +101,18 @@ type sequenceRange struct {
projection sequenceItemProjectionIndex projection sequenceItemProjectionIndex
} }
func (assigned sequenceRange) projections() []sequenceItemProjection {
if assigned.items != nil {
return append([]sequenceItemProjection{}, assigned.items...)
}
return sequenceItemProjections(assigned.end-assigned.start, assigned.start)
}
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) { func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
if assigned.projection.outputByInput != nil { if assigned.projection.outputByInput != nil {
return assigned.projection.outputForInput(inputIndex) return assigned.projection.outputForInput(inputIndex)
} }
if assigned.items != nil { if assigned.items != nil {
return newSequenceItemProjectionIndex(assigned.items).outputForInput(inputIndex) output, found := 0, false
for _, item := range assigned.items {
if item.inputIndex == inputIndex && inputIndex >= 0 && item.outputIndex >= 0 {
output, found = item.outputIndex, true
}
}
return output, found
} }
if inputIndex < 0 || inputIndex >= assigned.end-assigned.start { if inputIndex < 0 || inputIndex >= assigned.end-assigned.start {
return 0, false return 0, false
@@ -114,7 +125,13 @@ func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
return assigned.projection.inputForOutput(outputIndex) return assigned.projection.inputForOutput(outputIndex)
} }
if assigned.items != nil { if assigned.items != nil {
return newSequenceItemProjectionIndex(assigned.items).inputForOutput(outputIndex) input, found := 0, false
for _, item := range assigned.items {
if item.outputIndex == outputIndex && outputIndex >= 0 && item.inputIndex >= 0 {
input, found = item.inputIndex, true
}
}
return input, found
} }
if outputIndex < assigned.start || outputIndex >= assigned.end { if outputIndex < assigned.start || outputIndex >= assigned.end {
return 0, false return 0, false
@@ -123,8 +140,8 @@ func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
} }
func indexSequenceRange(assigned sequenceRange) sequenceRange { func indexSequenceRange(assigned sequenceRange) sequenceRange {
if assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil { if assigned.items != nil && assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
assigned.projection = newSequenceItemProjectionIndex(assigned.projections()) assigned.projection = newSequenceItemProjectionIndex(assigned.items)
} }
return assigned return assigned
} }
@@ -296,14 +313,10 @@ func resolveStrictCollectionPolicies(
valuePresent := make(map[string]bool) valuePresent := make(map[string]bool)
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations)) declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
for _, claim := range claims { for _, claim := range claims {
if len(lower.collections) != 0 {
valuePresent[claim.path.String()] = true
}
if claim.kind != document.Sequence {
continue
}
key := claim.path.String() key := claim.path.String()
if !seen[key] { valuePresent[key] = true
_, hasLower := lower.collections[key]
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
seen[key] = true seen[key] = true
paths = append(paths, claim.path.clone()) paths = append(paths, claim.path.clone())
} }
@@ -318,21 +331,18 @@ func resolveStrictCollectionPolicies(
} }
identityFieldsByPath := make(map[string][]string) identityFieldsByPath := make(map[string][]string)
effectiveLower := lower replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
if len(lower.collections) != 0 { lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy) var collectionPolicies collectionPolicyScope
}
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
var diagnostics []Diagnostic var diagnostics []Diagnostic
for _, path := range paths { for _, path := range paths {
key := path.String() key := path.String()
currentDeclarations := declarationsByPath[key] currentDeclarations := declarationsByPath[key]
lowerEntry, hasLower := effectiveLower.collections[key] lowerPolicyValue, hasLower := lowerView.at(path)
var lowerPolicy *collectionPolicy var lowerPolicy *collectionPolicy
if hasLower { if hasLower {
copy := cloneCollectionPolicy(lowerEntry.policy) lowerPolicy = &lowerPolicyValue
lowerPolicy = &copy
} }
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path) applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
@@ -349,7 +359,7 @@ func resolveStrictCollectionPolicies(
if hasLower && !valuePresent[key] { if hasLower && !valuePresent[key] {
// A declaration without a new value changes policy metadata but // A declaration without a new value changes policy metadata but
// leaves retained record ownership untouched. // leaves retained record ownership untouched.
policyForPath = cloneCollectionPolicy(lowerEntry.policy) policyForPath = cloneCollectionPolicy(lowerPolicyValue)
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields) policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
policyForPath.identityPresent = resolution.policy.identityPresent policyForPath.identityPresent = resolution.policy.identityPresent
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation) policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
@@ -358,7 +368,7 @@ func resolveStrictCollectionPolicies(
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation) policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
} }
if collectionPolicies.collections == nil { if collectionPolicies.collections == nil {
collectionPolicies.collections = make(map[string]collectionPolicyEntry) collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
} }
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath} collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
} }
@@ -459,8 +469,8 @@ func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
if assigned.items != nil { if assigned.items != nil {
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...) cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
} }
cloned[i].projection.outputByInput = append([]int(nil), assigned.projection.outputByInput...) cloned[i].projection.outputByInput = maps.Clone(assigned.projection.outputByInput)
cloned[i].projection.inputByOutput = append([]int(nil), assigned.projection.inputByOutput...) cloned[i].projection.inputByOutput = maps.Clone(assigned.projection.inputByOutput)
} }
return cloned return cloned
} }
@@ -474,21 +484,6 @@ func allMappingClaims(claims []compositionClaim) bool {
return true return true
} }
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
for _, incomingEntry := range incoming.Mapping {
position := index.find(target, incomingEntry.Key)
if position < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
index.append(target, cloned)
continue
}
// Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
}
}
func documentEntryIndex(entries []document.Entry, key string) int { func documentEntryIndex(entries []document.Entry, key string) int {
for index := range entries { for index := range entries {
if entries[index].Key == key { if entries[index].Key == key {
@@ -502,9 +497,15 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
origins := make(map[string]Origin) origins := make(map[string]Origin)
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs)) sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
for _, assigned := range ranges { for _, assigned := range ranges {
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], sequenceHistoryContribution{ contribution := sequenceHistoryContribution{
path: assigned.path.clone(), mode: sequenceHistoryMapped, items: assigned.projections(), path: assigned.path.clone(), mode: sequenceHistoryAppend,
}) offset: assigned.start, length: assigned.end - assigned.start,
}
if assigned.items != nil {
contribution.mode = sequenceHistoryMapped
contribution.items = assigned.items
}
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], contribution)
} }
for sourceIndex, input := range inputs { for sourceIndex, input := range inputs {
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]} application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}

View File

@@ -489,6 +489,96 @@ func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
} }
} }
func TestComposeDocumentsReturnsSparseCollectionPolicies(t *testing.T) {
itemsPath := Keys("items")
unrelatedPath := Keys("unrelated")
var lower collectionPolicyScope
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
unrelated := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
unrelated.setRecordPolicy(newCollectionRecordPolicy())
lower.set(unrelatedPath, unrelated)
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, nil,
parseCompositionDocument(t, "incoming", "items: [{id: current}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose incoming sequence: %#v", diagnostics)
}
if len(composed.collectionPolicies.collections) != 1 {
t.Fatalf("incoming collection policies = %#v; want only touched destinations", composed.collectionPolicies.collections)
}
if policy, found := composed.collectionPolicies.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
t.Fatalf("resolved incoming policy = %#v, found %t", policy, found)
}
if _, found := composed.collectionPolicies.at(unrelatedPath); found {
t.Fatal("incoming composition copied an unrelated lower policy")
}
if policy, found := lower.at(unrelatedPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"name"}) || len(policy.recordOrder) != 1 {
t.Fatalf("borrowed lower policy changed during composition: %#v, found %t", policy, found)
}
composed.collectionPolicies.collections[itemsPath.String()].policy.identityFields[0] = "changed"
if policy, _ := lower.at(itemsPath); !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
t.Fatalf("incoming policy shares identity storage with lower scope: %#v", policy)
}
}
func TestStrictCompositionMasksLowerIdentityForReplacedAncestor(t *testing.T) {
itemsPath := Keys("catalog", "items")
var lower collectionPolicyScope
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
declaration := sequenceDeclarationSpec{destination: Keys("catalog"), mode: sequenceModeReplace, modePresent: true}
_, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "catalog: {items: [{name: first}]}\n"),
parseCompositionDocument(t, "source-2", "catalog: {items: [{name: second}]}\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/catalog/items" {
t.Fatalf("strict peers used identity masked by replaced ancestor: %#v", diagnostics)
}
if policy, found := lower.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
t.Fatalf("strict resolution mutated borrowed lower identity: %#v, found %t", policy, found)
}
}
func TestComposeDocumentsDeclarationWithoutValueRetainsOwnedRecordPolicy(t *testing.T) {
path := Keys("items")
nestedPath := Keys("prompts")
var nested collectionPolicyScope
nested.set(nestedPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
record := collectionRecordPolicy{owner: newCollectionRecordPolicy().owner, scope: nested}
lowerPolicy := collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
}
lowerPolicy.setRecordPolicy(record)
var lower collectionPolicyScope
lower.set(path, lowerPolicy)
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "metadata", "other: value\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose declaration without a value: %#v", diagnostics)
}
resolved, found := composed.collectionPolicies.at(path)
if !found || !resolved.identityPresent || !reflect.DeepEqual(resolved.identityFields, []string{"id"}) || resolved.explicitMode != sequenceModeReplace || !resolved.explicitModePresent {
t.Fatalf("declaration-only result changed retained collection policy: %#v, found %t", resolved, found)
}
if len(resolved.recordOrder) != 1 || len(resolved.recordPolicies) != 1 {
t.Fatalf("declaration-only result lost record ownership: %#v", resolved)
}
owner := resolved.recordOrder[0]
resolved.recordPolicies[owner].collections[nestedPath.String()].policy.identityFields[0] = "changed"
retained, _ := lower.at(path)
if nestedPolicy, found := retained.recordPolicies[owner].at(nestedPath); !found || !reflect.DeepEqual(nestedPolicy.identityFields, []string{"name"}) {
t.Fatalf("declaration-only result borrowed nested record policy: %#v, found %t", nestedPolicy, found)
}
}
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) { func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
path := Keys("records") path := Keys("records")
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec { declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {

View File

@@ -43,9 +43,10 @@ func FuzzCompositionAndOverlay(f *testing.F) {
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 { if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
t.Fatalf("composition order changed effective values: %#v", differences) t.Fatalf("composition order changed effective values: %#v", differences)
} }
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"}) if mutateExistingDocumentDescendant(composed.root) {
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) { if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition result aliases an input document") t.Fatal("composition result aliases an input document")
}
} }
} }
@@ -60,9 +61,41 @@ func FuzzCompositionAndOverlay(f *testing.F) {
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) { if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay mutated an input document") t.Fatal("overlay mutated an input document")
} }
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"}) if mutateExistingDocumentDescendant(overlaid.root) {
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) { if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay result aliases an input document") t.Fatal("overlay result aliases an input document")
}
} }
}) })
} }
func mutateExistingDocumentDescendant(node *document.Node) bool {
if node == nil {
return false
}
// Empty containers have no existing element for the alias probe.
switch node.Kind {
case document.Mapping:
if len(node.Mapping) == 0 {
return false
}
entry := &node.Mapping[0]
if mutateExistingDocumentDescendant(entry.Value) {
return true
}
entry.Key += "-fuzz-detachment"
return true
case document.Sequence:
if len(node.Sequence) == 0 {
return false
}
if mutateExistingDocumentDescendant(node.Sequence[0]) {
return true
}
node.Sequence[0].Text += "fuzz-detachment"
return true
default:
node.Text += "fuzz-detachment"
return true
}
}

View File

@@ -22,7 +22,6 @@ type decodeIssue struct {
} }
type inputPresence struct { type inputPresence struct {
null bool
location document.Location location document.Location
} }
@@ -91,19 +90,30 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
} }
} }
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
var elementBase reflect.Value terminal, ok := terminalDescriptor(descriptor)
if base.IsNil() { if !ok {
elementBase = reflect.Zero(descriptor.typeOf.Elem()) return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration type cannot represent a non-null value")}
} else {
elementBase = base.Elem()
} }
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context) var pointers []*typeDescriptor
currentBase := base
for current := descriptor; current != terminal; current = current.element {
pointers = append(pointers, current)
if currentBase.IsNil() {
currentBase = reflect.Zero(current.typeOf.Elem())
} else {
currentBase = currentBase.Elem()
}
}
decoded, issues := decodeValue(node, currentBase, terminal, path, schema, context)
if len(issues) != 0 { if len(issues) != 0 {
return reflect.Value{}, issues return reflect.Value{}, issues
} }
result := reflect.New(descriptor.typeOf.Elem()) for i := len(pointers) - 1; i >= 0; i-- {
result.Elem().Set(decoded) result := reflect.New(pointers[i].typeOf.Elem())
return result, nil result.Elem().Set(decoded)
decoded = result
}
return decoded, nil
} }
switch descriptor.kind { switch descriptor.kind {
@@ -392,10 +402,11 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
} }
func acceptsMapping(descriptor *typeDescriptor) bool { func acceptsMapping(descriptor *typeDescriptor) bool {
for descriptor != nil && descriptor.kind == descriptorPointer { terminal, ok := terminalDescriptor(descriptor)
descriptor = descriptor.element if !ok {
return false
} }
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic) return terminal.kind == descriptorStruct || terminal.kind == descriptorMap || terminal.kind == descriptorDynamic
} }
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) { func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
@@ -422,7 +433,7 @@ func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Val
} }
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) { func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location} presence[path.String()] = inputPresence{location: node.Location}
switch node.Kind { switch node.Kind {
case document.Mapping: case document.Mapping:
for _, entry := range node.Mapping { for _, entry := range node.Mapping {

View File

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

View File

@@ -11,33 +11,13 @@ var (
errDestinationLimit = errors.New("configkit: destination exceeds document limits") errDestinationLimit = errors.New("configkit: destination exceeds document limits")
) )
type placementMode uint8 type PlacementMode uint8
const ( const (
placementModeMapping placementMode = iota PlacementModeMapping PlacementMode = iota
placementModeSequenceItem PlacementModeSequenceItem
) )
// PlacementModeFromFacade preserves root-owned numeric values until preflight
// can reject unsupported modes.
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
// PlacementModeForFacade converts private source evidence to the root-owned mode.
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
// InputWithPlacement copies the root-owned mode into a private engine input.
func InputWithPlacement(input Input, value uint8) Input {
input.placementMode = placementMode(value)
return input
}
// OptionsWithPlacement copies root-owned controls into private engine options.
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
options.destination = destination.clone()
options.placementMode = placementMode(value)
return options
}
func normalizeDestination(path Path) (Path, error) { func normalizeDestination(path Path) (Path, error) {
if !path.valid() { if !path.valid() {
return Path{}, errInvalidDestination return Path{}, errInvalidDestination
@@ -74,26 +54,26 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
// placeDocument checks all per-document bounds before allocating destination // placeDocument checks all per-document bounds before allocating destination
// wrappers. Aggregate accounting remains the caller's responsibility. // wrappers. Aggregate accounting remains the caller's responsibility.
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) { func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes) return placeDocumentWithMode(body, destination, PlacementModeMapping, sourceRoot, maxDepth, maxNodes)
} }
// placeDocumentWithMode places a source mapping beneath its literal destination. // placeDocumentWithMode places a source mapping beneath its literal destination.
// Sequence-item placement adds one sequence containing the complete mapping. // Sequence-item placement adds one sequence containing the complete mapping.
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) { func placeDocumentWithMode(body *document.Node, destination Path, mode PlacementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
normalized, err := normalizeDestination(destination) normalized, err := normalizeDestination(destination)
if err != nil { if err != nil {
return Path{}, nil, err return Path{}, nil, err
} }
if mode != placementModeMapping && mode != placementModeSequenceItem { if mode != PlacementModeMapping && mode != PlacementModeSequenceItem {
return Path{}, nil, errInvalidDestination return Path{}, nil, errInvalidDestination
} }
if mode == placementModeSequenceItem && len(normalized.segments) == 0 { if mode == PlacementModeSequenceItem && len(normalized.segments) == 0 {
return Path{}, nil, errInvalidDestination return Path{}, nil, errInvalidDestination
} }
if body == nil { if body == nil {
return normalized, nil, nil return normalized, nil, nil
} }
if mode == placementModeSequenceItem && body.Kind != document.Mapping { if mode == PlacementModeSequenceItem && body.Kind != document.Mapping {
return normalized, nil, errInvalidDestination return normalized, nil, errInvalidDestination
} }
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) { if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
@@ -101,7 +81,7 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
} }
placed := body placed := body
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
placed = &document.Node{ placed = &document.Node{
Kind: document.Sequence, Location: sourceRoot, Kind: document.Sequence, Location: sourceRoot,
Sequence: []*document.Node{placed}, Sequence: []*document.Node{placed},
@@ -125,20 +105,20 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
// destinationWithinLimits expects a normalized mapping-key destination. // destinationWithinLimits expects a normalized mapping-key destination.
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool { func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes) return destinationWithinLimitsWithMode(body, destination, PlacementModeMapping, maxDepth, maxNodes)
} }
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool { func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode PlacementMode, maxDepth, maxNodes int) bool {
if body == nil { if body == nil {
return true return true
} }
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) { if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != PlacementModeMapping && mode != PlacementModeSequenceItem) {
return false return false
} }
segments := len(destination.segments) segments := len(destination.segments)
sequenceNodes := 0 sequenceNodes := 0
sequenceDepth := 0 sequenceDepth := 0
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
if segments == 0 { if segments == 0 {
return false return false
} }
@@ -160,9 +140,9 @@ func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode
return countInputNodes(body) <= maxNodes-wrapperNodes return countInputNodes(body) <= maxNodes-wrapperNodes
} }
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int { func destinationWrapperNodeCountForMode(destination Path, mode PlacementMode) int {
count := destinationWrapperNodeCount(destination) count := destinationWrapperNodeCount(destination)
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
count++ count++
} }
return count return count

View File

@@ -34,17 +34,17 @@ func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta} accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{ acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: empty, destination: destination, source: empty, destination: destination,
}}}, 1, nil, accounting) }}}, 1, nil, accounting)
if test.wantError { if test.wantError {
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics) t.Fatalf("over-budget placement = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge { if acquired == nil || len(diagnostics) != 0 || acquired.inputNodes != test.wantCharge {
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics) t.Fatalf("bounded placement = %#v, %#v", acquired, diagnostics)
} }
}) })
} }
@@ -62,9 +62,9 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one")) perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
charge := len(inputs) * perInput charge := len(inputs) * perInput
accounting := &importAccounting{nodes: limits.nodes - charge} accounting := &importAccounting{nodes: limits.nodes - charge}
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting) acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge { if len(diagnostics) != 0 || acquired == nil || accounting.nodes != limits.nodes || acquired.inputNodes != charge {
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge) t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", acquired, diagnostics, accounting, charge)
} }
} }
@@ -92,9 +92,9 @@ id: parent
childDestination := appendDestination(destination, Keys("children")) childDestination := appendDestination(destination, Keys("children"))
otherDestination := Keys("other") otherDestination := Keys("other")
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) + wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) + destinationWrapperNodeCountForMode(destination, PlacementModeSequenceItem) +
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) + destinationWrapperNodeCountForMode(childDestination, PlacementModeSequenceItem) +
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem) destinationWrapperNodeCountForMode(otherDestination, PlacementModeSequenceItem)
limits := defaultImportLimits() limits := defaultImportLimits()
for _, test := range []struct { for _, test := range []struct {
@@ -107,22 +107,22 @@ id: parent
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta} accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{ acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem, source: File(mainPath), imports: true, destination: destination, placementMode: PlacementModeSequenceItem,
}, { }, {
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem, source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: PlacementModeSequenceItem,
}}}, 1, nil, accounting) }}}, 1, nil, accounting)
if test.wantError { if test.wantError {
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics) t.Fatalf("under-budget nested item glob = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 { if acquired == nil || len(diagnostics) != 0 || len(acquired.sources) != 3 {
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics) t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", acquired, acquired.sources, diagnostics)
} }
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta { if acquired.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta) t.Fatalf("nested item node charge = %d/%d, want %d/%d", acquired.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
} }
}) })
} }
@@ -160,7 +160,7 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta} accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{ composition, sources, _, diagnostics := testComposeUnderlayGroup(loadGroup{inputs: []loadInput{{
source: mainSource, imports: true, destination: destination, source: mainSource, imports: true, destination: destination,
}}}, 1, nil, accounting) }}}, 1, nil, accounting)
if test.wantError { if test.wantError {
@@ -216,24 +216,27 @@ func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
currentLimits := limits currentLimits := limits
currentLimits.nodes = wantNodes + test.delta currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{} accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked( request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil, acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
)
if test.wantError { if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics) t.Fatalf("below-bound relative placement = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 { if len(diagnostics) != 0 || acquired == nil || len(sources) != 2 {
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics) t.Fatalf("bounded relative placement = %#v, %#v, %#v", acquired, sources, diagnostics)
} }
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes { if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes) t.Fatalf("relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
} }
if sources[1].Destination.String() != importDestination.String() { if sources[1].Destination.String() != importDestination.String() {
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String()) t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
} }
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
if len(diagnostics) != 0 {
t.Fatalf("compose relative placement: %#v", diagnostics)
}
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty relative import body = %#v, present %v", node, present) t.Fatalf("empty relative import body = %#v, present %v", node, present)
} }
@@ -279,26 +282,29 @@ func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
currentLimits := limits currentLimits := limits
currentLimits.nodes = wantNodes + test.delta currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{} accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked( request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil, acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
)
if test.wantError { if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics) t.Fatalf("below-bound glob placement = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 { if len(diagnostics) != 0 || acquired == nil || len(sources) != len(fragmentPaths)+1 {
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics) t.Fatalf("bounded glob placement = %#v, %#v, %#v", acquired, sources, diagnostics)
} }
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes { if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes) t.Fatalf("glob relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
} }
for index, source := range sources[1:] { for index, source := range sources[1:] {
if source.Destination.String() != importDestination.String() { if source.Destination.String() != importDestination.String() {
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String()) t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
} }
} }
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
if len(diagnostics) != 0 {
t.Fatalf("compose glob placement: %#v", diagnostics)
}
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty glob contributions = %#v, present %v", node, present) t.Fatalf("empty glob contributions = %#v, present %v", node, present)
} }
@@ -366,15 +372,18 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
main := parseImportMain(t, mainPath) main := parseImportMain(t, mainPath)
limits := defaultImportLimits() limits := defaultImportLimits()
accounting := &importAccounting{} accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked( request := testImportRequest(File(mainPath), nil, main, destination, nil, nil)
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil, acquired, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
) if len(diagnostics) != 0 || acquired == nil || len(sources) != 1 {
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 { t.Fatalf("absent optional import = %#v, %#v, %#v", acquired, sources, diagnostics)
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
} }
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination) wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes { if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes) t.Fatalf("absent optional node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
}
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, acquired, sources)
if len(diagnostics) != 0 {
t.Fatalf("compose absent optional import: %#v", diagnostics)
} }
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty entry body = %#v, present %v", node, present) t.Fatalf("empty entry body = %#v, present %v", node, present)
@@ -387,12 +396,12 @@ func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
t.Parallel() t.Parallel()
accounting := &importAccounting{} accounting := &importAccounting{}
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{ acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
{source: NoSource(), destination: Keys("absent")}, {source: NoSource(), destination: Keys("absent")},
{source: Mapping("root", map[string]any{})}, {source: Mapping("root", map[string]any{})},
}}, 1, nil, accounting) }}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 { if len(diagnostics) != 0 || acquired == nil || len(acquired.sources) != 1 || accounting.nodes != 1 || acquired.inputNodes != 1 {
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting) t.Fatalf("root and absent placement = %#v, diagnostics %#v, accounting %#v", acquired, diagnostics, accounting)
} }
} }
@@ -412,7 +421,7 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}, overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
} }
accounting := &importAccounting{nodes: limits.nodes - baseNodes} accounting := &importAccounting{nodes: limits.nodes - baseNodes}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting) _, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
t.Fatalf("overlay diagnostics = %#v", diagnostics) t.Fatalf("overlay diagnostics = %#v", diagnostics)
} }
@@ -431,7 +440,7 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
overrides: []Override{override}, overrides: []Override{override},
} }
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1} accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
input, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting) input, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assembly diagnostics = %#v", diagnostics) t.Fatalf("assembly diagnostics = %#v", diagnostics)
} }
@@ -443,3 +452,15 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
} }
}) })
} }
func testAssembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
if accounting == nil {
accounting = &importAccounting{}
}
policy := acquisitionPolicy{
importDefaults: request.importDefaults,
sequencePolicy: request.sequencePolicy,
importLimits: defaultImportLimits(),
}
return assembleLayeredInput(request, input, policy, &acquisitionState{accounting: accounting, versionState: input.versionState})
}

View File

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

View File

@@ -201,7 +201,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}}, sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
} }
item := func(label string, value map[string]any) Input { item := func(label string, value map[string]any) Input {
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem} return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem}
} }
options := func(inputs ...Input) LayeredOptions[map[string]any] { options := func(inputs ...Input) LayeredOptions[map[string]any] {
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}} return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}

View File

@@ -22,11 +22,11 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
t.Parallel() t.Parallel()
loaded, err := LoadLayered[destinationItemConfig]( loaded, err := LoadLayered[destinationItemConfig](
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
testSecretBinding(), testSecretBinding(),
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"), Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
placementMode: placementModeSequenceItem, PlacementMode: PlacementModeSequenceItem,
}}}}}, }}}}},
) )
if err != nil { if err != nil {
@@ -40,7 +40,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup") t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved { if len(sources) != 1 || sources[0].placementMode != PlacementModeSequenceItem || sources[0].destinationUnresolved {
t.Fatalf("source placement evidence = %#v", sources) t.Fatalf("source placement evidence = %#v", sources)
} }
if got := sources[0].Destination.String(); got != "/catalog/entries" { if got := sources[0].Destination.String(); got != "/catalog/entries" {
@@ -48,7 +48,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
} }
absent, err := LoadLayered[destinationItemConfig]( absent, err := LoadLayered[destinationItemConfig](
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
testSecretBinding(), testSecretBinding(),
) )
if err != nil { if err != nil {
@@ -64,17 +64,17 @@ func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
mode placementMode mode PlacementMode
path Path path Path
}{ }{
{name: "root item", mode: placementModeSequenceItem}, {name: "root item", mode: PlacementModeSequenceItem},
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")}, {name: "unknown mode", mode: PlacementMode(255), path: Keys("catalog")},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
reader := bytes.NewBufferString("value: unused\n") reader := bytes.NewBufferString("value: unused\n")
_, err := LoadLayered[destinationItemConfig]( _, err := LoadLayered[destinationItemConfig](
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode}, Input{Source: Reader("reader", reader), Destination: test.path, PlacementMode: test.mode},
testSecretBinding(), testSecretBinding(),
) )
if err == nil || reader.Len() == 0 { if err == nil || reader.Len() == 0 {
@@ -93,7 +93,7 @@ func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
loaded, err := Load[destinationItemConfig]( loaded, err := Load[destinationItemConfig](
Mapping("ordinary", map[string]any{}), testSecretBinding(), Mapping("ordinary", map[string]any{}), testSecretBinding(),
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, Options[destinationItemConfig]{Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
) )
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)

View File

@@ -54,7 +54,7 @@ func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32) explicitPath, explicit, err := placeDocumentWithMode(body, destination, PlacementModeMapping, body.Location, 5, 32)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -235,7 +235,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
root := document.Location{SourceID: "item", Line: 1, Column: 1} root := document.Location{SourceID: "item", Line: 1, Column: 1}
body := &document.Node{Kind: document.Mapping, Location: root} body := &document.Node{Kind: document.Mapping, Location: root}
destination := Keys("catalog", "entries") destination := Keys("catalog", "entries")
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6) placedPath, placed, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, 3, 6)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -253,13 +253,13 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
t.Fatal("synthetic sequence or authored mapping lost source coordinates") t.Fatal("synthetic sequence or authored mapping lost source coordinates")
} }
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) { if _, _, err := placeDocumentWithMode(body, Path{}, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
t.Fatalf("root item placement error = %v, want invalid destination", err) t.Fatalf("root item placement error = %v, want invalid destination", err)
} }
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) { if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err) t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
} }
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) { if _, _, err := placeDocumentWithMode(body, destination, PlacementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
t.Fatalf("unknown placement mode error = %v, want invalid destination", err) t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
} }
@@ -275,7 +275,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes) _, _, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, test.depth, test.nodes)
if test.wantAccept && err != nil { if test.wantAccept && err != nil {
t.Fatalf("exact bounds rejected: %v", err) t.Fatalf("exact bounds rejected: %v", err)
} }

View File

@@ -19,11 +19,9 @@ const (
) )
type rootDirectiveExtraction struct { type rootDirectiveExtraction struct {
body *document.Node body *document.Node
imports []importDeclarationSpec imports []importDeclarationSpec
sequences []sequenceDeclarationSpec sequences []sequenceDeclarationSpec
importsPresent bool
sequencesPresent bool
} }
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) { func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
@@ -74,7 +72,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
fieldPath := directivePath.Key(entry.Key) fieldPath := directivePath.Key(entry.Key)
switch entry.Key { switch entry.Key {
case "imports": case "imports":
extracted.importsPresent = true
if context == rootDirectiveContextOverlay { if context == rootDirectiveContextOverlay {
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value)) diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
continue continue
@@ -87,7 +84,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
extracted.imports = append(extracted.imports, parsed...) extracted.imports = append(extracted.imports, parsed...)
diagnostics = append(diagnostics, issues...) diagnostics = append(diagnostics, issues...)
case "sequences": case "sequences":
extracted.sequencesPresent = true
if context == rootDirectiveContextImportsOnly { if context == rootDirectiveContextImportsOnly {
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value)) diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
continue continue

View File

@@ -255,7 +255,7 @@ func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics) t.Fatalf("parse diagnostics = %#v", diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("policy diagnostics = %#v", diagnostics) t.Fatalf("policy diagnostics = %#v", diagnostics)
} }

View File

@@ -34,7 +34,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -43,7 +43,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("selection diagnostics = %#v", diagnostics) t.Fatalf("selection diagnostics = %#v", diagnostics)
} }
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 3 { if len(selection.candidates) != 3 {
t.Fatalf("selection = %#v, want three unioned matches", selection) t.Fatalf("selection = %#v, want three unioned matches", selection)
} }
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" { if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
@@ -82,7 +82,7 @@ func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -141,7 +141,7 @@ func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -173,7 +173,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -188,7 +188,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics = resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -218,7 +218,7 @@ func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -254,7 +254,7 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
yaml string yaml string
defaults importDefaults defaults ImportDefaults
wantPaths []string wantPaths []string
}{ }{
{ {
@@ -280,13 +280,13 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
{ {
name: "explicit false overrides application recursion", name: "explicit false overrides application recursion",
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n", yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
defaults: importDefaults{recursive: boolPointer(true)}, defaults: ImportDefaults{Recursive: boolPointer(true)},
wantPaths: []string{"direct.yaml"}, wantPaths: []string{"direct.yaml"},
}, },
{ {
name: "application default enables recursion for depth", name: "application default enables recursion for depth",
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n", yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
defaults: importDefaults{recursive: boolPointer(true)}, defaults: ImportDefaults{Recursive: boolPointer(true)},
wantPaths: []string{"child.yaml", "direct.yaml"}, wantPaths: []string{"child.yaml", "direct.yaml"},
}, },
{ {
@@ -347,13 +347,13 @@ func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
accounting := &importAccounting{} accounting := &importAccounting{}
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting) selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
if len(diagnostics) != 0 || selection.outcome != importSelectionNone || len(selection.candidates) != 0 { if len(diagnostics) != 0 || len(selection.candidates) != 0 {
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics) t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
} }
if accounting.entries != 5 { if accounting.entries != 5 {

View File

@@ -0,0 +1,540 @@
package engine
import (
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
// Search is an immutable ordered description of possible host-file inputs.
// It does not access the filesystem until a later loading integration consumes it.
type Search struct {
entries []searchEntry
issues []string
}
// FilenameGroup is an immutable ordered group used by a directory search.
type FilenameGroup struct {
kind filenameGroupKind
names []string
issues []string
}
// DirectorySearchOptions controls the filename groups used by SearchDirectory.
// A nil Filenames slice selects the default groups; a non-nil empty slice selects none.
type DirectorySearchOptions struct {
Filenames []FilenameGroup
}
// SearchOptions controls whether a later source consumption requires a match.
type SearchOptions struct {
Required bool
}
// SearchConfig creates a source that selects the first matching host file when
// consumed. Description and option errors are reported during loading.
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)}
source := Source{kind: sourceSearch, search: &captured, searchApp: application}
if len(supplied) > 1 {
source.searchIssues = []string{"SearchConfig accepts at most one options value"}
} else if len(supplied) == 1 {
source.searchRequired = supplied[0].Required
}
return source
}
type filenameGroupKind uint8
const (
filenameGroupEmpty filenameGroupKind = iota
filenameGroupNamed
filenameGroupGeneric
filenameGroupCustom
)
type searchEntryKind uint8
const (
searchEntryPath searchEntryKind = iota + 1
searchEntryDirectory
)
type directoryBaseKind uint8
const (
directoryBaseLiteral directoryBaseKind = iota
directoryBaseUnixLocal
directoryBaseUnixEtc
directoryBaseUserConfig
directoryBaseHome
directoryBaseWorking
)
type searchEntry struct {
kind searchEntryKind
path string
baseKind directoryBaseKind
directory string
appendSlug bool
slugPrefix string
filenames []FilenameGroup
}
// NamedFilenames selects <application>.yml followed by <application>.yaml.
func NamedFilenames() FilenameGroup {
return FilenameGroup{kind: filenameGroupNamed}
}
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
func GenericFilenames() FilenameGroup {
return FilenameGroup{kind: filenameGroupGeneric}
}
// CustomFilenames selects literal filename components in the supplied order.
func CustomFilenames(names ...string) FilenameGroup {
group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)}
for _, name := range group.names {
if !validFilenameComponent(name) {
group.issues = append(group.issues, "custom filename must be a usable host filename component")
}
}
return group
}
// SearchDirectory searches the supplied directory using the selected filename groups.
// With omitted options or nil Filenames, it selects Named then Generic filenames.
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
search := Search{}
if len(supplied) > 1 {
search.issues = append(search.issues, "directory search accepts at most one options value")
}
var groups []FilenameGroup
if len(supplied) == 0 || supplied[0].Filenames == nil {
groups = []FilenameGroup{NamedFilenames(), GenericFilenames()}
} else {
groups = cloneFilenameGroups(supplied[0].Filenames)
}
search.entries = append(search.entries, searchEntry{
kind: searchEntryDirectory, baseKind: directoryBaseLiteral,
directory: directory, filenames: groups,
})
return search
}
// Paths describes literal host-file candidates in the supplied order.
func Paths(paths ...string) Search {
search := Search{entries: make([]searchEntry, 0, len(paths))}
for _, path := range append([]string(nil), paths...) {
search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path})
}
return search
}
// Searches concatenates descriptions in order and snapshots their entries.
func Searches(searches ...Search) Search {
result := Search{}
for _, search := range append([]Search(nil), searches...) {
result.entries = append(result.entries, cloneSearchEntries(search.entries)...)
result.issues = append(result.issues, search.issues...)
}
return result
}
// UnixSystemDirectories describes the conventional Unix system directories.
func UnixSystemDirectories() Search {
namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()}
return Search{entries: []searchEntry{
{kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}},
}}
}
// UserConfigDirectory describes the Go user-configuration directory searches.
func UserConfigDirectory() Search {
return Search{entries: []searchEntry{
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()},
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}},
}}
}
// HomeDirectory describes the Go home-directory searches.
func HomeDirectory() Search {
return Search{entries: []searchEntry{
{kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()},
{kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}},
}}
}
// WorkingDirectory describes application-named files in the current directory.
func WorkingDirectory() Search {
return Search{entries: []searchEntry{{
kind: searchEntryDirectory, baseKind: directoryBaseWorking,
filenames: []FilenameGroup{NamedFilenames()},
}}}
}
func defaultFilenameGroups() []FilenameGroup {
return []FilenameGroup{NamedFilenames(), GenericFilenames()}
}
func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup {
if groups == nil {
return nil
}
cloned := make([]FilenameGroup, len(groups))
for i, group := range groups {
cloned[i] = FilenameGroup{
kind: group.kind, names: append([]string(nil), group.names...),
issues: append([]string(nil), group.issues...),
}
}
return cloned
}
func cloneSearchEntries(entries []searchEntry) []searchEntry {
if entries == nil {
return nil
}
cloned := make([]searchEntry, len(entries))
for i, entry := range entries {
cloned[i] = entry
cloned[i].filenames = cloneFilenameGroups(entry.filenames)
}
return cloned
}
func validateSearch(search Search, application string) []Diagnostic {
var diagnostics []Diagnostic
for _, issue := range search.issues {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
}
slugNeeded := false
for _, entry := range search.entries {
switch entry.kind {
case searchEntryPath:
if entry.path == "" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty"))
}
case searchEntryDirectory:
if entry.baseKind == directoryBaseLiteral && entry.directory == "" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty"))
}
if entry.appendSlug {
slugNeeded = true
}
for _, group := range entry.filenames {
switch group.kind {
case filenameGroupEmpty, filenameGroupGeneric:
case filenameGroupNamed:
slugNeeded = true
case filenameGroupCustom:
default:
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid"))
}
for _, issue := range group.issues {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
}
}
default:
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid"))
}
}
if slugNeeded && !validFilenameComponent(application) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component"))
}
return canonicalDiagnostics(diagnostics)
}
func validFilenameComponent(value string) bool {
if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 {
return false
}
if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value {
return false
}
if runtime.GOOS == "windows" {
if !filepath.IsLocal(value) || strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
return false
}
for _, character := range value {
if character < 32 {
return false
}
}
}
return true
}
var errUnixSystemPresetUnavailable = errors.New("Unix system directories are unavailable on this platform")
type discoveryDirectoryHelpers struct {
userConfigDir func() (string, error)
userHomeDir func() (string, error)
workingDir func() (string, error)
unixSupported func() bool
}
type searchCandidateIterator struct {
search Search
application string
helpers discoveryDirectoryHelpers
entryIndex int
groupIndex int
nameIndex int
directory string
dirReady bool
}
func newSearchCandidateIterator(search Search, application string, helpers discoveryDirectoryHelpers) (*searchCandidateIterator, []Diagnostic) {
if diagnostics := validateSearch(search, application); len(diagnostics) != 0 {
return nil, diagnostics
}
defaults := defaultDiscoveryDirectoryHelpers()
if helpers.userConfigDir == nil {
helpers.userConfigDir = defaults.userConfigDir
}
if helpers.userHomeDir == nil {
helpers.userHomeDir = defaults.userHomeDir
}
if helpers.workingDir == nil {
helpers.workingDir = defaults.workingDir
}
if helpers.unixSupported == nil {
helpers.unixSupported = defaults.unixSupported
}
return &searchCandidateIterator{search: search, application: application, helpers: helpers}, nil
}
func defaultDiscoveryDirectoryHelpers() discoveryDirectoryHelpers {
return discoveryDirectoryHelpers{
userConfigDir: os.UserConfigDir,
userHomeDir: os.UserHomeDir,
workingDir: os.Getwd,
unixSupported: unixSystemPresetSupported,
}
}
// next expands one candidate at a time, resolving only the directory currently
// reached. Directory filenames are visited before moving to the next directory.
func (iterator *searchCandidateIterator) next() (string, bool, error) {
for {
if iterator.dirReady {
entry := iterator.search.entries[iterator.entryIndex]
for iterator.groupIndex < len(entry.filenames) {
group := entry.filenames[iterator.groupIndex]
count := filenameGroupLength(group)
if iterator.nameIndex < count {
name := filenameGroupAt(group, iterator.nameIndex, iterator.application)
iterator.nameIndex++
return filepath.Join(iterator.directory, name), true, nil
}
iterator.groupIndex++
iterator.nameIndex = 0
}
iterator.dirReady = false
iterator.entryIndex++
}
if iterator.entryIndex >= len(iterator.search.entries) {
return "", false, nil
}
entry := iterator.search.entries[iterator.entryIndex]
if entry.kind == searchEntryPath {
iterator.entryIndex++
path, err := iterator.absoluteCandidate(entry.path)
if err != nil {
return "", false, err
}
return path, true, nil
}
if !directoryHasCandidates(entry.filenames) {
iterator.entryIndex++
continue
}
directory, err := iterator.resolveDirectory(entry)
if err != nil {
return "", false, err
}
iterator.directory = directory
iterator.groupIndex = 0
iterator.nameIndex = 0
iterator.dirReady = true
}
}
func (iterator *searchCandidateIterator) absoluteCandidate(path string) (string, error) {
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
// Drive-relative and drive-root-relative Windows paths need native
// resolution; joining a rooted path to the whole working directory changes it.
if filepath.VolumeName(path) != "" || runtime.GOOS == "windows" && len(path) > 0 && os.IsPathSeparator(path[0]) {
return filepath.Abs(path)
}
working, err := iterator.helpers.workingDir()
if err != nil {
return "", err
}
return filepath.Clean(filepath.Join(working, path)), nil
}
func (iterator *searchCandidateIterator) resolveDirectory(entry searchEntry) (string, error) {
var base string
switch entry.baseKind {
case directoryBaseLiteral:
base = entry.directory
case directoryBaseUnixLocal:
if !iterator.helpers.unixSupported() {
return "", errUnixSystemPresetUnavailable
}
base = "/usr/local/etc"
case directoryBaseUnixEtc:
if !iterator.helpers.unixSupported() {
return "", errUnixSystemPresetUnavailable
}
base = "/etc"
case directoryBaseUserConfig:
var err error
base, err = iterator.helpers.userConfigDir()
if err != nil {
return "", err
}
case directoryBaseHome:
var err error
base, err = iterator.helpers.userHomeDir()
if err != nil {
return "", err
}
case directoryBaseWorking:
var err error
base, err = iterator.helpers.workingDir()
if err != nil {
return "", err
}
default:
return "", errors.New("directory search base is invalid")
}
if entry.appendSlug {
base = filepath.Join(base, entry.slugPrefix+iterator.application)
}
if entry.baseKind == directoryBaseLiteral && !filepath.IsAbs(base) {
return iterator.absoluteCandidate(base)
}
return filepath.Clean(base), nil
}
func directoryHasCandidates(groups []FilenameGroup) bool {
for _, group := range groups {
if filenameGroupLength(group) != 0 {
return true
}
}
return false
}
func filenameGroupLength(group FilenameGroup) int {
switch group.kind {
case filenameGroupNamed:
return 2
case filenameGroupGeneric:
return 4
case filenameGroupCustom:
return len(group.names)
default:
return 0
}
}
func filenameGroupAt(group FilenameGroup, index int, application string) string {
switch group.kind {
case filenameGroupNamed:
if index == 0 {
return application + ".yml"
}
return application + ".yaml"
case filenameGroupGeneric:
return [...]string{"config.yml", "config.yaml", "main.yml", "main.yaml"}[index]
case filenameGroupCustom:
return group.names[index]
default:
return ""
}
}
type discoveryHostProbe struct {
evalSymlinks func(string) (string, error)
stat func(string) (fs.FileInfo, error)
}
func defaultDiscoveryHostProbe() discoveryHostProbe {
return discoveryHostProbe{evalSymlinks: filepath.EvalSymlinks, stat: os.Stat}
}
// resolveSearchInput associates a terminal candidate failure with the same file
// evidence as ordinary acquisition. Exhaustion and base failures have no file.
func resolveSearchInput(source Source, sourceID string, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, *SourceInfo, []Diagnostic) {
selected, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, helpers, probe)
if len(diagnostics) == 0 || selected.kind != sourceFile {
return selected, nil, diagnostics
}
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerBase, Kind: SourceKindFile}
for index := range diagnostics {
diagnostics[index].Primary = &Location{SourceID: sourceID}
}
return selected, info, diagnostics
}
// resolveSearchSource selects one ordinary file source. A nil description leaves
// an existing source untouched; the selected file is read later by readSource.
// A terminal candidate failure returns its file descriptor for error evidence.
func resolveSearchSource(source Source, description *Search, application string, required bool, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, []Diagnostic) {
if description == nil {
return source, nil
}
iterator, diagnostics := newSearchCandidateIterator(*description, application, helpers)
if len(diagnostics) != 0 {
return Source{}, diagnostics
}
defaults := defaultDiscoveryHostProbe()
if probe.evalSymlinks == nil {
probe.evalSymlinks = defaults.evalSymlinks
}
if probe.stat == nil {
probe.stat = defaults.stat
}
for {
candidate, ok, err := iterator.next()
if err != nil {
if errors.Is(err, errUnixSystemPresetUnavailable) {
return Source{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Unix system directory search is unavailable on this platform")}
}
return Source{}, []Diagnostic{classifySourceError(err, "")}
}
if !ok {
if required {
return Source{}, []Diagnostic{sourceDiagnostic(CodeSourceMissing, "", "required configuration search found no file")}
}
return NoSource(), nil
}
canonical, err := probe.evalSymlinks(candidate)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
return File(candidate), []Diagnostic{classifySourceError(err, "")}
}
if _, err := probe.stat(canonical); err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
failed := File(candidate)
failed.path = canonical
return failed, []Diagnostic{classifySourceError(err, "")}
}
return File(candidate), nil
}
}

View File

@@ -0,0 +1,430 @@
package engine
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
type discoveryLoadConfig struct {
Service struct {
Settings map[string]string `yaml:"settings"`
} `yaml:"service"`
Settings map[string]string `yaml:"settings"`
Value string `yaml:"value"`
}
func TestSearchLoadMatchesExplicitFileThroughImportsAndContext(t *testing.T) {
t.Parallel()
root := t.TempDir()
mainPath := writeImportFile(t, root, "app.yml", `configkit:
imports: [{path: fragments/extra.yml}]
settings: {main: selected}
`)
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
search := SearchConfig("app", SearchDirectory(root))
options := Options[discoveryLoadConfig]{
Imports: true, Filesystem: &FilesystemContext{Root: root}, Destination: Keys("service"),
}
searched, err := testLoad(search, options)
if err != nil {
t.Fatal(err)
}
explicit, err := testLoad(File(mainPath), options)
if err != nil {
t.Fatal(err)
}
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
Keys("service", "settings", "main"), Keys("service", "settings", "imported"))
sources := searched.Sources()
if len(sources) != 2 || sources[0].Kind != SourceKindFile || sources[0].Path != canonicalTestPath(t, mainPath) || sources[1].Path != canonicalTestPath(t, importPath) {
t.Fatalf("searched source evidence = %#v", sources)
}
}
func TestSearchLayeredMainAndUnderlayMatchExplicitFiles(t *testing.T) {
t.Parallel()
root := t.TempDir()
mainPath := writeImportFile(t, root, "main.yml", "settings: {main: value}\n")
underlayPath := writeImportFile(t, root, "underlay/base.yml", `configkit:
imports: [{path: extra.yml}]
settings: {underlay: value}
`)
importPath := writeImportFile(t, root, "underlay/extra.yml", "settings: {imported: value}\n")
mainSearch := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("main.yml")}}))
underlaySearch := SearchConfig("", SearchDirectory(filepath.Join(root, "underlay"), DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("base.yml")}}))
load := func(main, underlay Source) (*Loaded[discoveryLoadConfig], error) {
return LoadLayered(
Input{Source: main, Imports: true}, testSecretBinding(),
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: underlay, Imports: true}}}}},
)
}
searched, err := load(mainSearch, underlaySearch)
if err != nil {
t.Fatal(err)
}
explicit, err := load(File(mainPath), File(underlayPath))
if err != nil {
t.Fatal(err)
}
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
sources := searched.Inspection.Sources()
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
t.Fatalf("layered search source evidence = %#v", sources)
}
}
func TestOptionalSearchExhaustionMatchesNoSource(t *testing.T) {
t.Parallel()
root := t.TempDir()
missing := filepath.Join(root, "missing.yml")
defaults := func() discoveryLoadConfig {
return discoveryLoadConfig{Value: "default"}
}
options := Options[discoveryLoadConfig]{
Definition: Definition[discoveryLoadConfig]{Defaults: defaults},
Imports: true, Filesystem: &FilesystemContext{Root: root},
}
searched, err := testLoad(SearchConfig("", Paths(missing)), options)
if err != nil {
t.Fatal(err)
}
explicit, err := testLoad(NoSource(), options)
if err != nil {
t.Fatal(err)
}
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
if len(searched.Sources()) != 0 {
t.Fatalf("optional exhausted search created sources: %#v", searched.Sources())
}
}
func TestSearchSelectedFileFailuresDoNotFallBack(t *testing.T) {
t.Parallel()
t.Run("malformed YAML", func(t *testing.T) {
first := writeImportFile(t, t.TempDir(), "first.yml", ": malformed\n")
writeImportFile(t, filepath.Dir(first), "later.yml", "value: valid\n")
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(filepath.Dir(first), "later.yml"))), Options[discoveryLoadConfig]{})
if loaded != nil || err == nil {
t.Fatalf("malformed selected source = %#v, %v", loaded, err)
}
})
t.Run("version check", func(t *testing.T) {
root := t.TempDir()
first := writeImportFile(t, root, "first.yml", "version: rejected\n")
writeImportFile(t, root, "later.yml", "version: accepted\n")
var checked []string
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[discoveryLoadConfig]{
SourceVersionMode: sourceVersionModeIfPresent,
Definition: Definition[discoveryLoadConfig]{Version: &VersionRule{
Path: Keys("version"),
Check: func(version Version, _ *Validation) error {
checked = append(checked, version.Text)
if version.Text == "rejected" {
return errors.New("version rejected")
}
return nil
},
}},
})
if loaded != nil || err == nil || !reflect.DeepEqual(checked, []string{"rejected"}) {
t.Fatalf("version failure search = %#v, %v; checked %#v", loaded, err, checked)
}
})
t.Run("decode", func(t *testing.T) {
root := t.TempDir()
first := writeImportFile(t, root, "first.yml", "value: not-an-integer\n")
writeImportFile(t, root, "later.yml", "value: 3\n")
type config struct {
Value int `yaml:"value"`
}
loaded, err := testLoad[config](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[config]{})
if loaded != nil || err == nil {
t.Fatalf("decode failure search = %#v, %v", loaded, err)
}
})
}
func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
t.Parallel()
root := t.TempDir()
main := writeImportFile(t, root, "main.yml", "value: main\n")
underlay := writeImportFile(t, root, "underlay.yml", "settings: {first: underlay}\n")
bad := filepath.Join(main, "child.yml")
later := writeImportFile(t, root, "later.yml", "value: later\n")
missing := filepath.Join(root, "missing.yml")
for _, entry := range []struct {
name string
id string
load func(Source) (*Loaded[discoveryLoadConfig], error)
}{
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Destination: Keys("settings")})
}},
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
Underlays: []Underlay{{Inputs: []Input{
{Source: File(underlay)}, {Source: source, Destination: Keys("service", "settings")},
}}},
})
}},
} {
t.Run(entry.name, func(t *testing.T) {
_, err := entry.load(File(bad))
var explicit *LoadError
if !errors.As(err, &explicit) || len(explicit.Diagnostics()) != 1 {
t.Fatalf("explicit failed file = %v", err)
}
if explicit.Diagnostics()[0].Code == CodeSourceMissing {
t.Skip("host classifies a non-directory ancestor as absence; controlled probe tests cover access failures")
}
result, err := entry.load(SearchConfig("", Paths(missing, bad, later)))
var searched *LoadError
if result != nil || !errors.As(err, &searched) {
t.Fatalf("failed search = %#v, %v", result, err)
}
if !reflect.DeepEqual(searched.Sources(), explicit.Sources()) || !reflect.DeepEqual(searched.Diagnostics(), explicit.Diagnostics()) {
t.Fatalf("search failure differs from explicit file:\nsources %#v / %#v\ndiagnostics %#v / %#v", searched.Sources(), explicit.Sources(), searched.Diagnostics(), explicit.Diagnostics())
}
primary := searched.Diagnostics()[0].Primary
if primary == nil || primary.SourceID != entry.id {
t.Fatalf("failed candidate location = %#v, want %s", primary, entry.id)
}
})
}
}
func TestSearchValidationPrecedesCandidateProbing(t *testing.T) {
t.Parallel()
root := t.TempDir()
first := writeImportFile(t, root, "first.yml", "value: selected\n")
_, err := testLoad[discoveryLoadConfig](SearchConfig("", Searches(Paths(first), Paths(""))), Options[discoveryLoadConfig]{})
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("invalid later search error = %v, want *LoadError", err)
}
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
t.Fatalf("invalid later search component diagnostics = %#v", loadErr.Diagnostics())
}
malformed := writeImportFile(t, t.TempDir(), "main.yml", ": malformed\n")
loaded, err := LoadLayered(
Input{Source: SearchConfig("", Paths(malformed))}, testSecretBinding(),
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: SearchConfig("", Paths(""))}}}}},
)
if loaded != nil || !errors.As(err, &loadErr) {
t.Fatalf("invalid later underlay search = %#v, %v", loaded, err)
}
for _, diagnostic := range loadErr.Diagnostics() {
if diagnostic.Code != CodeOptionsInvalid {
t.Fatalf("layered validation acquired main before rejecting underlay: %#v", loadErr.Diagnostics())
}
}
}
func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) {
t.Parallel()
root := t.TempDir()
first := writeImportFile(t, root, "first.yml", "value: first\n")
writeImportFile(t, root, "later.yml", "value: later\n")
search := SearchConfig("", Paths(first, filepath.Join(root, "later.yml")))
options := Options[discoveryLoadConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}}
_, err := testLoad[discoveryLoadConfig](search, options)
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("selected file with explicit base error = %v, want *LoadError", err)
}
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
t.Fatalf("selected file context diagnostics = %#v", loadErr.Diagnostics())
}
}
func TestSearchEmptyContextUsesSelectedOutcome(t *testing.T) {
t.Parallel()
root := t.TempDir()
selected := writeImportFile(t, root, "selected.yml", "value: selected\n")
missing := filepath.Join(root, "missing.yml")
context := &FilesystemContext{}
for _, entry := range []struct {
name string
load func(Source) (*Loaded[discoveryLoadConfig], error)
}{
{"ordinary", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Imports: true, Filesystem: context})
}},
{"layered main", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return LoadLayered(Input{Source: source, Imports: true, Filesystem: context}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{})
}},
{"underlay", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
Underlays: []Underlay{{Inputs: []Input{{Source: source, Imports: true, Filesystem: context}}}},
})
}},
} {
t.Run(entry.name, func(t *testing.T) {
explicit, err := entry.load(File(selected))
if err != nil {
t.Fatal(err)
}
searched, err := entry.load(SearchConfig("", Paths(missing, selected)))
if err != nil {
t.Fatal(err)
}
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
if searched.Config.Value != "selected" {
t.Fatalf("selected value = %q", searched.Config.Value)
}
for _, source := range []Source{NoSource(), SearchConfig("", Paths(missing))} {
result, err := entry.load(source)
var failure *LoadError
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
t.Fatalf("absent input with empty context = %#v, %v", result, err)
}
}
})
}
}
func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) {
t.Parallel()
root := t.TempDir()
file := writeImportFile(t, root, "config.yml", "value: first\n")
source := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("config.yml")}}))
load := func() string {
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
if err != nil {
t.Fatal(err)
}
return loaded.Config.Value
}
if value := load(); value != "first" {
t.Fatalf("first search value = %q", value)
}
if err := os.WriteFile(file, []byte("value: second\n"), 0o600); err != nil {
t.Fatal(err)
}
if value := load(); value != "second" {
t.Fatalf("updated search value = %q", value)
}
}
func TestSearchSourceObservesUserConfigEnvironmentChanges(t *testing.T) {
setBase := func(directory string) string {
t.Helper()
switch runtime.GOOS {
case "windows":
t.Setenv("AppData", directory)
case "darwin", "ios":
t.Setenv("HOME", directory)
case "plan9":
t.Setenv("home", directory)
default:
t.Setenv("XDG_CONFIG_HOME", directory)
}
base, err := os.UserConfigDir()
if err != nil {
t.Fatal(err)
}
return base
}
type config struct {
Value string `yaml:"value"`
}
firstBase := setBase(filepath.Join(t.TempDir(), "first"))
first := writeImportFile(t, firstBase, "app/app.yml", "value: first\n")
source := SearchConfig("app", UserConfigDirectory())
load := func() string {
loaded, err := testLoad[config](source, Options[config]{})
if err != nil {
t.Fatal(err)
}
return loaded.Config.Value
}
if value := load(); value != "first" {
t.Fatalf("first user-config search value = %q (%s)", value, first)
}
secondBase := setBase(filepath.Join(t.TempDir(), "second"))
second := writeImportFile(t, secondBase, "app/app.yml", "value: second\n")
if value := load(); value != "second" {
t.Fatalf("updated user-config search value = %q (%s)", value, second)
}
}
func TestSearchSourceObservesWorkingDirectoryChanges(t *testing.T) {
original, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(original); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
firstDir := t.TempDir()
secondDir := t.TempDir()
writeImportFile(t, firstDir, "app.yml", "value: first\n")
writeImportFile(t, secondDir, "app.yml", "value: second\n")
source := SearchConfig("app", WorkingDirectory())
load := func() string {
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
if err != nil {
t.Fatal(err)
}
return loaded.Config.Value
}
if err := os.Chdir(firstDir); err != nil {
t.Fatal(err)
}
if value := load(); value != "first" {
t.Fatalf("first working-directory search value = %q", value)
}
if err := os.Chdir(secondDir); err != nil {
t.Fatal(err)
}
if value := load(); value != "second" {
t.Fatalf("updated working-directory search value = %q", value)
}
}
func assertEquivalentDiscoveryLoads[T any](t *testing.T, leftConfig T, left *Inspection, rightConfig T, right *Inspection, presencePaths ...Path) {
t.Helper()
if !reflect.DeepEqual(leftConfig, rightConfig) {
t.Fatalf("search config %#v differs from explicit file config %#v", leftConfig, rightConfig)
}
leftSources, rightSources := left.Sources(), right.Sources()
if !reflect.DeepEqual(leftSources, rightSources) {
t.Fatalf("search sources %#v differ from explicit file sources %#v", leftSources, rightSources)
}
leftMetadata, rightMetadata := left.Metadata(), right.Metadata()
for _, path := range presencePaths {
leftInfo, leftPresent := leftMetadata.Lookup(path)
rightInfo, rightPresent := rightMetadata.Lookup(path)
if leftPresent != rightPresent || !reflect.DeepEqual(leftInfo, rightInfo) {
t.Fatalf("search metadata at %s = %#v, %v; explicit metadata = %#v, %v", path.String(), leftInfo, leftPresent, rightInfo, rightPresent)
}
}
for _, source := range leftSources {
leftContext, leftOK := left.SourceContext(source.ID)
rightContext, rightOK := right.SourceContext(source.ID)
if leftOK != rightOK || leftContext.BaseDir != rightContext.BaseDir || (leftContext.FS != nil) != (rightContext.FS != nil) {
t.Fatalf("search context for %s = %#v, %v; explicit context = %#v, %v", source.ID, leftContext, leftOK, rightContext, rightOK)
}
}
}

View File

@@ -0,0 +1,5 @@
//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !hurd && !illumos && !ios && !linux && !netbsd && !openbsd && !solaris
package engine
func unixSystemPresetSupported() bool { return false }

View File

@@ -0,0 +1,5 @@
//go:build aix || android || darwin || dragonfly || freebsd || hurd || illumos || ios || linux || netbsd || openbsd || solaris
package engine
func unixSystemPresetSupported() bool { return true }

View File

@@ -0,0 +1,519 @@
package engine
import (
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) {
t.Parallel()
if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 {
t.Fatalf("zero search diagnostics = %#v", diagnostics)
}
if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 {
t.Fatalf("empty concatenation diagnostics = %#v", diagnostics)
}
if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 {
t.Fatalf("empty path list diagnostics = %#v", diagnostics)
}
if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 {
t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics)
}
generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}})
if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 {
t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics)
}
if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid {
t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics)
}
if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 {
t.Fatalf("valid preset slug diagnostics = %#v", diagnostics)
}
}
func TestSearchDescriptionSnapshotsInputs(t *testing.T) {
t.Parallel()
paths := []string{"first.yml", "second.yml"}
pathSearch := Paths(paths...)
paths[0] = "changed.yml"
customNames := []string{"one.yml", "two.yml"}
group := CustomFilenames(customNames...)
customNames[0] = "changed.yml"
groups := []FilenameGroup{group}
options := DirectorySearchOptions{Filenames: groups}
directorySearch := SearchDirectory("configs", options)
groups[0] = GenericFilenames()
options.Filenames[0] = NamedFilenames()
searches := []Search{pathSearch, directorySearch}
combined := Searches(searches...)
searches[0] = Search{}
if got := combined.entries[0].path; got != "first.yml" {
t.Fatalf("snapshotted path = %q", got)
}
gotNames := combined.entries[2].filenames[0].names
if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" {
t.Fatalf("snapshotted custom names = %#v", gotNames)
}
if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 {
t.Fatalf("snapshot description diagnostics = %#v", diagnostics)
}
}
func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) {
t.Parallel()
omitted := SearchDirectory("configs")
if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric {
t.Fatalf("omitted groups = %#v", omitted.entries)
}
explicitNil := SearchDirectory("configs", DirectorySearchOptions{})
if len(explicitNil.entries[0].filenames) != 2 {
t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames)
}
explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}})
if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 {
t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames)
}
if len(validateSearch(explicitEmpty, "")) != 0 {
t.Fatal("explicit empty filename selection should need no slug")
}
}
func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) {
t.Parallel()
tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{})
if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 {
t.Fatal("multiple option values should be invalid")
}
invalid := Paths("")
combined := Searches(Paths("good.yml"), invalid)
if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 {
t.Fatal("concatenation lost a constructor error")
}
if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 {
t.Fatal("later invalid path was hidden by an earlier component")
}
}
func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) {
t.Parallel()
groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")}
search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups})
got := search.entries[0].filenames
if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" {
t.Fatalf("filename group order = %#v", got)
}
if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 {
t.Fatalf("empty custom group = %#v", got)
}
}
func TestFilenameComponentValidation(t *testing.T) {
t.Parallel()
for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} {
if validFilenameComponent(value) {
t.Errorf("validFilenameComponent(%q) = true", value)
}
}
if runtime.GOOS == "windows" {
for _, value := range []string{"CON", "CONIN$", "CONOUT$", "name.", "bad:name", "bad\x01name"} {
if validFilenameComponent(value) {
t.Errorf("Windows component %q unexpectedly accepted", value)
}
}
// Device names with extensions depend on the Windows version. Match
// Go's platform-aware decision instead of imposing a blanket ban.
for _, value := range []string{"nul.txt", "COM¹.log", "LPT³.txt", "CON.extra.yml"} {
if got, want := validFilenameComponent(value), filepath.IsLocal(value); got != want {
t.Errorf("Windows component %q validity = %v, want Go's %v", value, got, want)
}
}
} else {
for _, value := range []string{"CONIN$", "CONOUT$", "nul.txt"} {
if !validFilenameComponent(value) {
t.Errorf("Windows component restriction applied to this host: %q", value)
}
}
}
for _, value := range []string{"name with spaces", "naïve"} {
if !validFilenameComponent(value) {
t.Errorf("host-valid component %q rejected", value)
}
}
if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 {
t.Fatal("invalid custom filename was not retained for validation")
}
}
func TestSearchCandidatesPreserveDirectoryAndGroupOrder(t *testing.T) {
t.Parallel()
root := t.TempDir()
helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
search := Searches(
Paths("first.yml"),
SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{
GenericFilenames(), CustomFilenames("special.yml"),
}}),
Paths("last.yml"),
)
candidates := collectSearchCandidates(t, search, "app", helpers)
want := []string{
filepath.Join(root, "first.yml"),
filepath.Join(root, "settings", "config.yml"),
filepath.Join(root, "settings", "config.yaml"),
filepath.Join(root, "settings", "main.yml"),
filepath.Join(root, "settings", "main.yaml"),
filepath.Join(root, "settings", "special.yml"),
filepath.Join(root, "last.yml"),
}
assertCandidatePaths(t, candidates, want)
reversed := SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{NamedFilenames(), GenericFilenames()}})
reversedCandidates := collectSearchCandidates(t, reversed, "app", helpers)
assertCandidatePaths(t, reversedCandidates[:2], []string{
filepath.Join(root, "settings", "app.yml"),
filepath.Join(root, "settings", "app.yaml"),
})
}
func TestSearchPresetCandidateLists(t *testing.T) {
t.Parallel()
helpers := discoveryDirectoryHelpers{
userConfigDir: func() (string, error) { return "/controlled/config", nil },
userHomeDir: func() (string, error) { return "/controlled/home", nil },
workingDir: func() (string, error) { return "/controlled/work", nil },
unixSupported: func() bool { return true },
}
unix := collectSearchCandidates(t, UnixSystemDirectories(), "app", helpers)
if len(unix) != 14 {
t.Fatalf("Unix preset candidate count = %d: %#v", len(unix), unix)
}
assertCandidatePaths(t, unix, []string{
"/usr/local/etc/app/app.yml", "/usr/local/etc/app/app.yaml", "/usr/local/etc/app/config.yml", "/usr/local/etc/app/config.yaml", "/usr/local/etc/app/main.yml", "/usr/local/etc/app/main.yaml",
"/etc/app/app.yml", "/etc/app/app.yaml", "/etc/app/config.yml", "/etc/app/config.yaml", "/etc/app/main.yml", "/etc/app/main.yaml",
"/etc/app.yml", "/etc/app.yaml",
})
user := collectSearchCandidates(t, UserConfigDirectory(), "app", helpers)
assertCandidatePaths(t, user, []string{
"/controlled/config/app/app.yml", "/controlled/config/app/app.yaml", "/controlled/config/app/config.yml", "/controlled/config/app/config.yaml", "/controlled/config/app/main.yml", "/controlled/config/app/main.yaml",
"/controlled/config/app.yml", "/controlled/config/app.yaml",
})
home := collectSearchCandidates(t, HomeDirectory(), "app", helpers)
assertCandidatePaths(t, home, []string{
"/controlled/home/.app/app.yml", "/controlled/home/.app/app.yaml", "/controlled/home/.app/config.yml", "/controlled/home/.app/config.yaml", "/controlled/home/.app/main.yml", "/controlled/home/.app/main.yaml",
"/controlled/home/app.yml", "/controlled/home/app.yaml",
})
working := collectSearchCandidates(t, WorkingDirectory(), "app", helpers)
assertCandidatePaths(t, working, []string{"/controlled/work/app.yml", "/controlled/work/app.yaml"})
}
func TestSearchCandidateExpansionIsLazyAndSkipsEmptyDirectories(t *testing.T) {
t.Parallel()
configCalls := 0
helpers := discoveryDirectoryHelpers{
userConfigDir: func() (string, error) {
configCalls++
return "/controlled/config", nil
},
workingDir: func() (string, error) { return "/controlled/work", nil },
unixSupported: func() bool { return true },
}
search := Searches(Paths("/selected.yml"), UserConfigDirectory())
iterator, diagnostics := newSearchCandidateIterator(search, "app", helpers)
if len(diagnostics) != 0 {
t.Fatalf("iterator diagnostics = %#v", diagnostics)
}
if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/selected.yml" {
t.Fatalf("first candidate = %q, %v, %v", candidate, ok, err)
}
if configCalls != 0 {
t.Fatalf("later user directory resolved before consumption: %d calls", configCalls)
}
emptyOnly := SearchDirectory("should-not-resolve", DirectorySearchOptions{Filenames: []FilenameGroup{}})
emptyCandidates := collectSearchCandidates(t, emptyOnly, "", helpers)
if len(emptyCandidates) != 0 || configCalls != 0 {
t.Fatalf("empty directory candidates = %#v, helper calls = %d", emptyCandidates, configCalls)
}
continued := collectSearchCandidates(t, Searches(emptyOnly, Paths("later.yml")), "", helpers)
assertCandidatePaths(t, continued, []string{filepath.Join("/controlled/work", "later.yml")})
if configCalls != 0 {
t.Fatalf("empty directory base was resolved: %d calls", configCalls)
}
}
func TestSearchCandidateBaseFailuresAndPlatformApplicability(t *testing.T) {
t.Parallel()
baseFailure := errors.New("controlled base failure")
helpers := discoveryDirectoryHelpers{
userConfigDir: func() (string, error) { return "", baseFailure },
workingDir: func() (string, error) { return "/controlled/work", nil },
unixSupported: func() bool { return false },
}
iterator, diagnostics := newSearchCandidateIterator(UserConfigDirectory(), "app", helpers)
if len(diagnostics) != 0 {
t.Fatalf("user preset validation = %#v", diagnostics)
}
if _, ok, err := iterator.next(); ok || !errors.Is(err, baseFailure) {
t.Fatalf("unavailable user base = ok %v, err %v", ok, err)
}
iterator, diagnostics = newSearchCandidateIterator(UnixSystemDirectories(), "app", helpers)
if len(diagnostics) != 0 {
t.Fatalf("Unix preset validation = %#v", diagnostics)
}
if _, ok, err := iterator.next(); ok || !errors.Is(err, errUnixSystemPresetUnavailable) {
t.Fatalf("unsupported Unix preset = ok %v, err %v", ok, err)
}
iterator, diagnostics = newSearchCandidateIterator(Searches(Paths("/earlier.yml"), UnixSystemDirectories()), "app", helpers)
if len(diagnostics) != 0 {
t.Fatalf("composed Unix preset validation = %#v", diagnostics)
}
if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/earlier.yml" {
t.Fatalf("earlier candidate = %q, %v, %v", candidate, ok, err)
}
}
func TestRelativeAndShellLikeSearchPathsRemainLiteral(t *testing.T) {
t.Parallel()
root := t.TempDir()
helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
search := Searches(Paths("~/literal.yml", "$CONFIG/*.yaml"), SearchDirectory("./settings", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("name with spaces.yml")}}))
candidates := collectSearchCandidates(t, search, "", helpers)
assertCandidatePaths(t, candidates, []string{
filepath.Join(root, "~/literal.yml"),
filepath.Join(root, "$CONFIG/*.yaml"),
filepath.Join(root, "settings", "name with spaces.yml"),
})
}
func collectSearchCandidates(t *testing.T, search Search, application string, helpers discoveryDirectoryHelpers) []string {
t.Helper()
iterator, diagnostics := newSearchCandidateIterator(search, application, helpers)
if len(diagnostics) != 0 {
t.Fatalf("search validation = %#v", diagnostics)
}
var candidates []string
for {
candidate, ok, err := iterator.next()
if err != nil {
t.Fatalf("expand candidate: %v", err)
}
if !ok {
return candidates
}
candidates = append(candidates, candidate)
}
}
func assertCandidatePaths(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("candidate count = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("candidate %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestResolveSearchSelectsFirstExistingCandidate(t *testing.T) {
t.Parallel()
root := t.TempDir()
first := filepath.Join(root, "first.yml")
second := filepath.Join(root, "second.yml")
writeDiscoveryFile(t, first, "value: first\n")
writeDiscoveryFile(t, second, "value: second\n")
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(filepath.Join(root, "missing.yml"), first, second)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || selected.kind != sourceFile || selected.path != first {
t.Fatalf("selection = %#v, diagnostics %#v", selected, diagnostics)
}
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(read.data) != "value: first\n" || read.info.Path != canonicalTestPath(t, first) {
t.Fatalf("selected source read = %#v, diagnostics %#v", read, diagnostics)
}
}
func TestResolveSearchNamedExtensionsAndNonregularSelection(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeDiscoveryFile(t, filepath.Join(root, "app.yml"), "value: yml\n")
writeDiscoveryFile(t, filepath.Join(root, "app.yaml"), "value: yaml\n")
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(SearchDirectory(root)), "app", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || selected.path != filepath.Join(root, "app.yml") {
t.Fatalf("named extension selection = %#v, %#v", selected, diagnostics)
}
directory := filepath.Join(root, "subdirectory")
if err := os.Mkdir(directory, 0o700); err != nil {
t.Fatal(err)
}
good := filepath.Join(root, "good.yml")
writeDiscoveryFile(t, good, "value: later\n")
selected, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(directory, good)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || selected.path != directory {
t.Fatalf("directory candidate selection = %#v, %#v", selected, diagnostics)
}
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
t.Fatalf("selected directory read diagnostics = %#v", diagnostics)
}
}
func TestResolveSearchContainedSymlinkAndNoFallbackAfterReadFailure(t *testing.T) {
t.Parallel()
root := t.TempDir()
target := filepath.Join(root, "actual.yml")
link := filepath.Join(root, "linked.yml")
writeDiscoveryFile(t, target, "value: linked\n")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
writeDiscoveryFile(t, filepath.Join(root, "later.yml"), "value: later\n")
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(link, filepath.Join(root, "later.yml"))), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || selected.path != link {
t.Fatalf("symlink selection = %#v, %#v", selected, diagnostics)
}
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(read.data) != "value: linked\n" || read.info.Path != canonicalTestPath(t, target) {
t.Fatalf("symlink read = %#v, %#v", read, diagnostics)
}
if err := os.Remove(target); err != nil {
t.Fatal(err)
}
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
t.Fatalf("selected source read failure = %#v", diagnostics)
}
}
func TestResolveSearchRequiredAndOptionalExhaustion(t *testing.T) {
t.Parallel()
optional, diagnostics := resolveSearchSource(Source{}, ptrSearch(Search{}), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || optional.kind != sourceNone {
t.Fatalf("optional empty search = %#v, %#v", optional, diagnostics)
}
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Search{}), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing || diagnostics[0].Primary != nil || !strings.Contains(diagnostics[0].Message, "search") {
t.Fatalf("required empty search = %#v", diagnostics)
}
root := t.TempDir()
missing := filepath.Join(root, "missing.yml")
optional, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || optional.kind != sourceNone {
t.Fatalf("optional missing search = %#v, %#v", optional, diagnostics)
}
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
t.Fatalf("required missing search = %#v", diagnostics)
}
}
func TestResolveSearchLeavesNonSearchSourcesUntouched(t *testing.T) {
t.Parallel()
source := Bytes("memory", []byte("value: unchanged\n"))
resolved, diagnostics := resolveSearchSource(source, nil, "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
if len(diagnostics) != 0 || resolved.kind != sourceBytes || string(resolved.data) != "value: unchanged\n" {
t.Fatalf("non-search source = %#v, diagnostics = %#v", resolved, diagnostics)
}
}
func TestResolveSearchStopsOnReachedProbeError(t *testing.T) {
t.Parallel()
root := t.TempDir()
first := filepath.Join(root, "first.yml")
second := filepath.Join(root, "second.yml")
writeDiscoveryFile(t, first, "value: first\n")
writeDiscoveryFile(t, second, "value: second\n")
statCalls := 0
probe := discoveryHostProbe{stat: func(path string) (fs.FileInfo, error) {
statCalls++
return nil, fs.ErrPermission
}}
selected, info, diagnostics := resolveSearchInput(SearchConfig("", Paths(first, second)), "source-3", discoveryDirectoryHelpers{}, probe)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourcePermission || statCalls != 1 {
t.Fatalf("probe failure = %#v, stat calls = %d", diagnostics, statCalls)
}
if selected.kind != sourceFile || info == nil || info.Path != canonicalTestPath(t, first) || info.Label != first || info.Kind != SourceKindFile || info.ID != "source-3" {
t.Fatalf("failed candidate evidence = %#v, %#v", selected, info)
}
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != info.ID {
t.Fatalf("failed candidate diagnostic does not identify its file: %#v", diagnostics)
}
}
func TestResolveSearchLeavesLaterDirectoryUnresolvedAfterSelection(t *testing.T) {
t.Parallel()
root := t.TempDir()
first := filepath.Join(root, "selected.yml")
writeDiscoveryFile(t, first, "value: selected\n")
baseCalls := 0
helpers := discoveryDirectoryHelpers{userConfigDir: func() (string, error) {
baseCalls++
return "", errors.New("later base should not be reached")
}}
search := ptrSearch(Searches(Paths(first), UserConfigDirectory()))
selected, diagnostics := resolveSearchSource(Source{}, search, "app", false, helpers, discoveryHostProbe{})
if len(diagnostics) != 0 || selected.path != first || baseCalls != 0 {
t.Fatalf("selected = %#v, diagnostics = %#v, later base calls = %d", selected, diagnostics, baseCalls)
}
}
func TestResolveSearchTreatsShellLikeNamesLiterally(t *testing.T) {
t.Parallel()
root := t.TempDir()
paths := []string{"~/literal.yml", "$CONFIG/literal.yml", "*.yaml"}
for _, name := range paths {
writeDiscoveryFile(t, filepath.Join(root, name), "value: literal\n")
}
helper := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
for _, name := range paths {
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(name)), "", false, helper, discoveryHostProbe{})
if len(diagnostics) != 0 || selected.path != filepath.Join(root, name) {
t.Fatalf("literal %q selection = %#v, %#v", name, selected, diagnostics)
}
}
}
func ptrSearch(search Search) *Search { return &search }
func writeDiscoveryFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,91 @@
//go:build windows
package engine
import (
"errors"
"path/filepath"
"strings"
"testing"
)
func TestWindowsSearchRejectsReservedComponentsBeforeAcquisition(t *testing.T) {
t.Parallel()
root := t.TempDir()
first := writeImportFile(t, root, "first.yml", "value: first\n")
for _, name := range []string{"CONIN$", "CONOUT$"} {
for _, source := range []Source{
SearchConfig(name, Searches(Paths(first), WorkingDirectory())),
SearchConfig("", Searches(Paths(first), SearchDirectory(root, DirectorySearchOptions{
Filenames: []FilenameGroup{CustomFilenames(name)},
}))),
} {
result, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
var failure *LoadError
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
t.Fatalf("reserved component %q accepted: %#v, %v", name, result, err)
}
if len(failure.Sources()) != 0 {
t.Fatalf("reserved component %q was checked after acquisition: %#v", name, failure.Sources())
}
}
}
}
func TestWindowsSearchPathsMatchHostAbsoluteResolution(t *testing.T) {
root := t.TempDir()
file := writeImportFile(t, root, "settings/config.yml", "value: selected\n")
working := t.TempDir()
t.Chdir(working)
relative, err := filepath.Rel(working, file)
if err != nil {
t.Fatal(err)
}
volume := filepath.VolumeName(file)
rooted := strings.TrimPrefix(file, volume)
for _, entry := range []struct {
name string
path string
}{
{"relative", relative},
{"drive root relative", rooted},
{"slash root relative", filepath.ToSlash(rooted)},
{"drive relative", volume + relative},
{"absolute", file},
} {
t.Run(entry.name, func(t *testing.T) {
want, err := filepath.Abs(entry.path)
if err != nil {
t.Fatal(err)
}
explicit, err := testLoad[discoveryLoadConfig](File(entry.path))
if err != nil {
t.Fatal(err)
}
for _, search := range []Search{
Paths(entry.path),
SearchDirectory(filepath.Dir(entry.path), DirectorySearchOptions{
Filenames: []FilenameGroup{CustomFilenames(filepath.Base(entry.path))},
}),
} {
iterator, diagnostics := newSearchCandidateIterator(search, "", discoveryDirectoryHelpers{})
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
candidate, ok, err := iterator.next()
if err != nil || !ok || candidate != want {
t.Fatalf("candidate = %q, %v, %v; want %q", candidate, ok, err, want)
}
result, err := testLoad[discoveryLoadConfig](SearchConfig("", search))
if err != nil || result.Config.Value != explicit.Config.Value {
t.Fatalf("searched path = %#v, %v; explicit value %q", result, err, explicit.Config.Value)
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Path != explicit.Sources()[0].Path {
t.Fatalf("searched source = %#v; explicit source %#v", sources, explicit.Sources())
}
}
})
}
}

View File

@@ -0,0 +1,160 @@
package engine
import (
"errors"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type unsupportedDynamicRoot struct {
Child unsupportedDynamicChild `yaml:"child"`
Invalid chan int `yaml:"invalid"`
}
type unsupportedDynamicChild struct {
Parent *unsupportedDynamicRoot `yaml:"parent"`
}
type secretDynamicRoot struct {
Nested secretDynamicNested `yaml:"nested"`
}
type secretDynamicNested struct {
Token Secret `yaml:"token"`
}
type dynamicTransactionConfig struct {
First any `yaml:"first"`
Second any `yaml:"second"`
Good any `yaml:"good"`
}
func TestDynamicDescriptorFailuresRollBackProvisionalTypes(t *testing.T) {
called := false
result, err := testLoad[dynamicTransactionConfig](NoSource(), Options[dynamicTransactionConfig]{Definition: Definition[dynamicTransactionConfig]{
Resolve: func(config *dynamicTransactionConfig, resolution *Resolution) error {
called = true
Normalize(resolution, Keys("first"), &config.First, any((*unsupportedDynamicRoot)(nil)), "try typed nil")
Normalize(resolution, Keys("second"), &config.Second, any(&unsupportedDynamicRoot{}), "try non-nil")
Normalize(resolution, Keys("good"), &config.Good, any(map[string]string{"valid": "value"}), "continue with supported value")
return nil
},
}})
var loadErr *LoadError
if !called || result != nil || !errors.As(err, &loadErr) {
t.Fatalf("result = %#v, called=%v, error=%v", result, called, err)
}
paths := make(map[string]Code)
for _, diagnostic := range loadErr.Diagnostics() {
paths[diagnostic.Path.String()] = diagnostic.Code
}
for _, path := range []string{"/first", "/second"} {
if paths[path] != CodeResolveInvalidValue {
t.Errorf("diagnostic at %s = %q, want safe invalid-value failure", path, paths[path])
}
}
schema := mustSchemaFor[dynamicTransactionConfig](t)
operation := valueOperation{schema: schema}
prior, issue := operation.dynamicDescriptor(reflect.TypeFor[map[string]string](), Keys("prior"))
if issue != nil {
t.Fatalf("prior valid dynamic descriptor issue = %#v", issue)
}
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("failure")); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("unsupported dynamic descriptor issue = %#v", issue)
}
if _, approved := operation.approvedDynamic[reflect.TypeFor[unsupportedDynamicRoot]()]; approved {
t.Fatal("unsupported dynamic descriptor was cached as eligible")
}
if schema.types[reflect.TypeFor[map[string]string]()] != prior {
t.Fatal("failed build removed a previously completed descriptor")
}
for _, current := range []reflect.Type{
reflect.TypeFor[unsupportedDynamicRoot](),
reflect.TypeFor[unsupportedDynamicChild](),
reflect.TypeFor[*unsupportedDynamicRoot](),
} {
if _, exists := schema.types[current]; exists {
t.Errorf("failed build retained provisional descriptor for %v", current)
}
}
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("again")); issue == nil || issue.path.String() != "/again" {
t.Fatalf("repeated failure issue = %#v", issue)
}
secretSchema, schemaIssue := buildSchema(reflect.TypeFor[dynamicTransactionConfig](), testSecretBinding())
if schemaIssue != nil {
t.Fatalf("secret schema issue = %#v", schemaIssue)
}
secretOperation := valueOperation{schema: secretSchema}
if _, issue := secretOperation.dynamicDescriptor(reflect.TypeFor[secretDynamicRoot](), Keys("secret")); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic secret eligibility issue = %#v", issue)
}
if len(secretOperation.approvedDynamic) != 0 {
t.Fatalf("secret-bearing dynamic descriptor was cached as eligible: %#v", secretOperation.approvedDynamic)
}
for _, current := range []reflect.Type{reflect.TypeFor[secretDynamicRoot](), reflect.TypeFor[secretDynamicNested](), reflect.TypeFor[Secret]()} {
if _, exists := secretSchema.types[current]; exists {
t.Errorf("secret eligibility failure retained provisional descriptor for %v", current)
}
}
}
type wideDynamicEligibilityValue struct {
Alpha string
Bravo string
Charlie string
Delta string
Echo string
Foxtrot string
Golf string
Hotel string
India string
Juliet string
Kilo string
Lima string
Mike string
November string
Oscar string
Papa string
}
type repeatedNilDynamicEligibilityConfig struct {
Values []any
}
func TestDynamicEligibilityIsReusedWithinProjectionOperation(t *testing.T) {
schema, schemaIssue := buildSchema(reflect.TypeFor[repeatedNilDynamicEligibilityConfig](), testSecretBinding())
if schemaIssue != nil {
t.Fatalf("schema issue = %#v", schemaIssue)
}
values := make([]any, 32)
for index := range values {
values[index] = (*wideDynamicEligibilityValue)(nil)
}
configuration := repeatedNilDynamicEligibilityConfig{Values: values}
operation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
node, issue := operation.project(reflect.ValueOf(configuration), schema.root, Path{}, 0)
if issue != nil {
t.Fatalf("repeated typed-nil projection issue = %#v", issue)
}
if len(node.Mapping) != 1 || len(node.Mapping[0].Value.Sequence) != len(values) {
t.Fatalf("projected repeated values = %#v", node)
}
for index, child := range node.Mapping[0].Value.Sequence {
if child.Kind != document.Null {
t.Errorf("projected value %d kind = %v, want null", index, child.Kind)
}
}
current := reflect.TypeFor[*wideDynamicEligibilityValue]()
if len(operation.approvedDynamic) != 1 || operation.approvedDynamic[current] == nil ||
operation.approvedDynamic[current] != schema.types[current] {
t.Fatalf("operation eligibility cache = %#v", operation.approvedDynamic)
}
otherOperation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
if len(otherOperation.approvedDynamic) != 0 {
t.Fatalf("dynamic eligibility leaked into another operation: %#v", otherOperation.approvedDynamic)
}
}

View File

@@ -308,12 +308,18 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) { func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
leaf := &document.Node{Kind: document.String, Text: "value"} leaf := &document.Node{Kind: document.String, Text: "value"}
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}} root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
applications := []historyApplication{{layer: LayerBase, value: root}} inspection := &Inspection{
if !historyNodesWithin(2, root, root, root, applications, nil) { baseline: root, input: root, final: root,
applications: []historyApplication{{layer: LayerBase, value: root}},
}
counter := newRetainedNodeCounter(2)
if !addInspectionHistory(counter, inspection) || counter.count != 2 {
t.Fatal("structurally shared history was counted more than once") t.Fatal("structurally shared history was counted more than once")
} }
other := document.Clone(root) other := document.Clone(root)
if historyNodesWithin(3, root, other, root, applications, nil) { inspection.input = other
counter = newRetainedNodeCounter(3)
if addInspectionHistory(counter, inspection) {
t.Fatal("distinct retained nodes did not consume the budget") t.Fatal("distinct retained nodes did not consume the budget")
} }
} }

View File

@@ -3,40 +3,52 @@ package engine
import ( import (
"io/fs" "io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
) )
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic { func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
if nativeContext(source, context) { resolved, diagnostic := resolveFilesystemContext(source, context, main, hostToken)
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
return diagnostic
}
return validateNativeDirectories(access, basePath, mainPath)
}
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil { if diagnostic != nil {
return diagnostic // Keep absent-input validation's existing diagnostic while sharing
// root/base derivation with overlay and resource-context handling.
if source.kind == sourceNone && context.FS == nil && diagnostic.Code == CodeImportOutsideRoot {
diagnostic.Message = "import base must stay inside the configuration root"
}
return resolvedFilesystemContext{}, diagnostic
} }
root, err := os.OpenRoot(rootPath) if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
return resolvedFilesystemContext{}, diagnostic
}
if resolved.native != nil {
return resolved, nil
}
root, err := os.OpenRoot(resolved.rootPath)
if err != nil { if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{}) value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
value.Primary = nil value.Primary = nil
return &value return resolvedFilesystemContext{}, &value
} }
if err := root.Close(); err != nil { if err := root.Close(); err != nil {
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil) value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
return &value return resolvedFilesystemContext{}, &value
} }
return nil return resolved, nil
} }
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic { func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
switch source.kind { switch source.kind {
case sourceNone: case sourceNone:
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")} return absentFilesystemOptionDiagnostics(context)
case sourceSearch:
if context.FS != nil {
return absentFilesystemOptionDiagnostics(context)
}
// A selected host file can derive its root; absence requires an
// explicit root. Check that compatibility only after selection.
if context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem base directory must be an absolute host path")}
}
case sourceFile: case sourceFile:
if context.FS != nil { if context.FS != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")} return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
@@ -69,115 +81,19 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
return nil return nil
} }
func nativeContext(source Source, context *FilesystemContext) bool { func absentFilesystemOptionDiagnostics(context *FilesystemContext) []Diagnostic {
return source.kind == sourceFilesystem || context != nil && context.FS != nil if context.FS != nil {
} if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")}
// nativeFilesystemAccess resolves namespace context without opening caller
// resources. File inputs always derive their base from the selected file.
func nativeFilesystemAccess(source Source, context *FilesystemContext) (*filesystemAccess, string, string, *Diagnostic) {
var namespace *nativeNamespace
rootPath := "."
basePath := "."
mainPath := ""
if source.kind == sourceFilesystem {
namespace = source.namespace
mainPath = source.path
basePath = path.Dir(source.path)
if context != nil && context.Root != "" {
rootPath = context.Root
}
} else if context != nil && context.FS != nil {
namespace = context.FS.namespace
rootPath, basePath = context.Root, context.BaseDir
}
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(rootPath) || !fs.ValidPath(basePath) {
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
return nil, "", "", &value
}
access := newNativeAccess(namespace.filesystem, rootPath, namespace.token)
access.label = namespace.label
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return nil, "", "", &value
}
return access, basePath, mainPath, nil
}
func validateNativeDirectories(access *filesystemAccess, basePath, mainPath string) *Diagnostic {
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return &value
}
for _, directory := range []string{access.rootPath, basePath} {
info, err := access.stat(directory)
if err != nil || !info.IsDir() {
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
return &value
} }
} else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")}
} }
return nil return nil
} }
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) { func nativeContext(source Source, context *FilesystemContext) bool {
if main.source == nil || main.document == nil { return source.kind == sourceFilesystem || context != nil && context.FS != nil
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
return "", "", "", &value
}
if source.kind == sourceFile {
if context != nil && context.BaseDir != "" {
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
return "", "", "", &value
}
canonical, err := canonicalExistingPath(source.path)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
return "", "", "", &value
}
mainPath = canonical
basePath = filepath.Dir(canonical)
rootPath = basePath
if context != nil && context.Root != "" {
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return "", "", "", &value
}
}
} else {
if context == nil || context.BaseDir == "" || context.Root == "" {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
return "", "", "", &value
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
return "", "", "", &value
}
var err error
basePath, err = canonicalExistingPath(context.BaseDir)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
return "", "", "", &value
}
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return "", "", "", &value
}
}
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return "", "", "", &value
}
for _, candidate := range []string{rootPath, basePath} {
info, err := os.Stat(candidate)
if err != nil || !info.IsDir() {
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
return "", "", "", &value
}
}
return rootPath, basePath, mainPath, nil
} }
func canonicalExistingPath(name string) (string, error) { func canonicalExistingPath(name string) (string, error) {

View File

@@ -141,40 +141,59 @@ func (a *filesystemAccess) open(name string) (fs.File, error) {
return a.root.Open(relative) return a.root.Open(relative)
} }
type directoryReadError struct {
opened bool
err error
}
func (e *directoryReadError) Error() string { return "directory could not be read" }
func (e *directoryReadError) Unwrap() error { return e.err }
func missingDirectoryOpen(err error) bool {
var failure *directoryReadError
return errors.As(err, &failure) && !failure.opened && errors.Is(err, fs.ErrNotExist)
}
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error { func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
file, err := a.open(name) file, err := a.open(name)
if err != nil { if err != nil {
return err return &directoryReadError{err: err}
}
failure := func(err error) error {
if err == nil {
return nil
}
return &directoryReadError{opened: true, err: err}
} }
if reader, ok := file.(fs.ReadDirFile); ok { if reader, ok := file.(fs.ReadDirFile); ok {
for { for {
entries, readErr := reader.ReadDir(globReadBatch) entries, readErr := reader.ReadDir(globReadBatch)
for _, entry := range entries { for _, entry := range entries {
if !visit(entry) { if !visit(entry) {
return file.Close() return failure(file.Close())
} }
} }
if errors.Is(readErr, io.EOF) { if errors.Is(readErr, io.EOF) {
return file.Close() return failure(file.Close())
} }
if readErr != nil { if readErr != nil {
_ = file.Close() _ = file.Close()
return readErr return failure(readErr)
} }
if len(entries) == 0 { if len(entries) == 0 {
_ = file.Close() _ = file.Close()
return io.ErrNoProgress return failure(io.ErrNoProgress)
} }
} }
} }
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
return err return failure(err)
} }
// fs.ReadDir provides the standard fallback for implementations whose opened // fs.ReadDir provides the standard fallback for implementations whose opened
// files do not implement fs.ReadDirFile. It may allocate all entries itself. // files do not implement fs.ReadDirFile. It may allocate all entries itself.
entries, err := fs.ReadDir(a.native, name) entries, err := fs.ReadDir(a.native, name)
if err != nil { if err != nil {
return err return failure(err)
} }
for _, entry := range entries { for _, entry := range entries {
if !visit(entry) { if !visit(entry) {

View File

@@ -0,0 +1,176 @@
package engine
import (
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
type resolvedFilesystemContext struct {
native *nativeNamespace
hostToken *namespaceToken
rootPath string
basePath string
mainPath string
}
func resolveFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
if source.kind == sourceNone && context != nil {
return resolveAbsentFilesystemContext(context)
}
if nativeContext(source, context) {
return resolveNativeFilesystemContext(source, context)
}
return resolveHostFilesystemContext(source, context, main, hostToken)
}
func resolveAbsentFilesystemContext(context *FilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
if context.FS != nil {
return resolveNativeFilesystemContext(Source{kind: sourceNone}, context)
}
rootPath, err := canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return resolvedFilesystemContext{}, &value
}
basePath := rootPath
if context.BaseDir != "" {
basePath, err = canonicalExistingPath(context.BaseDir)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
return resolvedFilesystemContext{}, &value
}
}
if !containedPath(rootPath, basePath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return resolvedFilesystemContext{}, &value
}
return resolvedFilesystemContext{rootPath: rootPath, basePath: basePath}, nil
}
func resolveNativeFilesystemContext(source Source, context *FilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
var namespace *nativeNamespace
resolved := resolvedFilesystemContext{rootPath: ".", basePath: "."}
if source.kind == sourceFilesystem {
namespace = source.namespace
resolved.mainPath = source.path
resolved.basePath = path.Dir(source.path)
if context != nil && context.Root != "" {
resolved.rootPath = context.Root
}
} else if context != nil && context.FS != nil {
namespace = context.FS.namespace
resolved.rootPath, resolved.basePath = context.Root, context.BaseDir
}
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(resolved.rootPath) || !fs.ValidPath(resolved.basePath) {
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
return resolvedFilesystemContext{}, &value
}
resolved.native = namespace
if !containsNativePath(resolved.rootPath, resolved.basePath) || resolved.mainPath != "" && !containsNativePath(resolved.rootPath, resolved.mainPath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return resolvedFilesystemContext{}, &value
}
return resolved, nil
}
func resolveHostFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
if main.source == nil {
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
return resolvedFilesystemContext{}, &value
}
resolved := resolvedFilesystemContext{hostToken: hostToken}
if source.kind == sourceFile {
if context != nil && context.BaseDir != "" {
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
return resolvedFilesystemContext{}, &value
}
canonical := main.source.Path
if canonical == "" {
var err error
canonical, err = canonicalExistingPath(source.path)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
return resolvedFilesystemContext{}, &value
}
}
resolved.mainPath = canonical
resolved.basePath = filepath.Dir(canonical)
resolved.rootPath = resolved.basePath
if context != nil && context.Root != "" {
rootPath, err := canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return resolvedFilesystemContext{}, &value
}
resolved.rootPath = rootPath
}
} else {
if context == nil || context.BaseDir == "" || context.Root == "" {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
return resolvedFilesystemContext{}, &value
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
return resolvedFilesystemContext{}, &value
}
var err error
resolved.basePath, err = canonicalExistingPath(context.BaseDir)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
return resolvedFilesystemContext{}, &value
}
resolved.rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return resolvedFilesystemContext{}, &value
}
}
if !containedPath(resolved.rootPath, resolved.basePath) || resolved.mainPath != "" && !containedPath(resolved.rootPath, resolved.mainPath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return resolvedFilesystemContext{}, &value
}
return resolved, nil
}
func containsNativePath(root, name string) bool {
if !fs.ValidPath(name) || !fs.ValidPath(root) {
return false
}
return root == "." || name == root || strings.HasPrefix(name, root+"/")
}
func validateResolvedFilesystemDirectories(resolved resolvedFilesystemContext) *Diagnostic {
for _, directory := range []string{resolved.rootPath, resolved.basePath} {
var info fs.FileInfo
var err error
if resolved.native != nil {
info, err = fs.Stat(resolved.native.filesystem, directory)
} else {
info, err = os.Stat(directory)
}
if err != nil || !info.IsDir() {
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
return &value
}
}
return nil
}
func openResolvedFilesystemAccess(resolved resolvedFilesystemContext) (*filesystemAccess, error) {
if resolved.native != nil {
access := newNativeAccess(resolved.native.filesystem, resolved.rootPath, resolved.native.token)
access.label = resolved.native.label
return access, nil
}
access, err := openHostAccess(resolved.rootPath)
if err != nil {
return nil, err
}
if resolved.hostToken != nil {
access.token = resolved.hostToken
}
return access, nil
}

View File

@@ -119,21 +119,34 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
} }
// A link stays active until its target has resolved, not until the entire // A link remains active until its own target resolves. Completed targets can be
// original pathname has resolved. Remaining segments may reuse a finished link. // reused while the caller continues resolving its remaining components.
type importLinkFrame struct { type importLinkFrame struct {
pending []string pending []string
link string link string
} }
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) { type importLinkOperations struct {
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}} lstat func(string) (fs.FileInfo, error)
readlink func(string) (string, error)
normalizeTarget func(string, string) (string, error)
}
func resolveImportLinkPath(name string, operations importLinkOperations) (string, error) {
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
resolved := "" resolved := ""
activeLinks := make(map[string]struct{}) active := make(map[string]struct{})
completed := make(map[string]string)
for len(frames) > 0 { for len(frames) > 0 {
frame := &frames[len(frames)-1] frame := &frames[len(frames)-1]
if len(frame.pending) == 0 { if len(frame.pending) == 0 {
delete(activeLinks, frame.link) if frame.link != "" {
if resolved == "" {
resolved = "."
}
completed[frame.link] = resolved
delete(active, frame.link)
}
frames = frames[:len(frames)-1] frames = frames[:len(frames)-1]
continue continue
} }
@@ -143,7 +156,7 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
if candidate == "" { if candidate == "" {
candidate = "." candidate = "."
} }
info, err := access.root.Lstat(filepath.FromSlash(candidate)) info, err := operations.lstat(candidate)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -151,27 +164,22 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
resolved = candidate resolved = candidate
continue continue
} }
if _, exists := activeLinks[candidate]; exists { if target, ok := completed[candidate]; ok {
resolved = target
continue
}
if _, exists := active[candidate]; exists {
return "", errImportDirectoryLinkCycle return "", errImportDirectoryLinkCycle
} }
activeLinks[candidate] = struct{}{} target, err := operations.readlink(candidate)
target, err := access.root.Readlink(filepath.FromSlash(candidate))
if err != nil { if err != nil {
return "", err return "", err
} }
var targetPath string targetPath, err := operations.normalizeTarget(candidate, target)
if filepath.IsAbs(target) { if err != nil {
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target)) return "", err
if err != nil {
return "", errImportPathOutsideRoot
}
targetPath = path.Clean(filepath.ToSlash(relative))
} else {
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
}
if !withinPortableRoot(targetPath) {
return "", errImportPathOutsideRoot
} }
active[candidate] = struct{}{}
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate}) frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
resolved = "" resolved = ""
} }
@@ -181,6 +189,37 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
return resolved, nil return resolved, nil
} }
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
return resolveImportLinkPath(filepath.ToSlash(name), hostImportLinkOperations(access))
}
func hostImportLinkOperations(access *filesystemAccess) importLinkOperations {
return importLinkOperations{
lstat: func(candidate string) (fs.FileInfo, error) {
return access.root.Lstat(filepath.FromSlash(candidate))
},
readlink: func(candidate string) (string, error) {
return access.root.Readlink(filepath.FromSlash(candidate))
},
normalizeTarget: func(candidate, target string) (string, error) {
var targetPath string
if filepath.IsAbs(target) {
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
if err != nil {
return "", errImportPathOutsideRoot
}
targetPath = path.Clean(filepath.ToSlash(relative))
} else {
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
}
if !withinPortableRoot(targetPath) {
return "", errImportPathOutsideRoot
}
return targetPath, nil
},
}
}
func withinPortableRoot(name string) bool { func withinPortableRoot(name string) bool {
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/") return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
} }
@@ -189,57 +228,27 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
if !fs.ValidPath(name) || !access.contains(name) { if !fs.ValidPath(name) || !access.contains(name) {
return "", fs.ErrInvalid return "", fs.ErrInvalid
} }
frames := []importLinkFrame{{pending: strings.Split(name, "/")}} return resolveImportLinkPath(name, importLinkOperations{
resolved := "" lstat: func(candidate string) (fs.FileInfo, error) {
activeLinks := make(map[string]struct{}) // Ancestors of a multi-component root are resolution prefixes, not
for len(frames) > 0 { // selected targets. Complete paths and link targets remain contained.
frame := &frames[len(frames)-1] if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
if len(frame.pending) == 0 { return nil, errImportPathOutsideRoot
delete(activeLinks, frame.link) }
frames = frames[:len(frames)-1] return filesystem.Lstat(candidate)
continue },
} readlink: filesystem.ReadLink,
segment := frame.pending[0] normalizeTarget: func(candidate, target string) (string, error) {
frame.pending = frame.pending[1:] if target == "" || strings.HasPrefix(target, "/") {
candidate := path.Join(resolved, segment) return "", errImportPathOutsideRoot
if candidate == "" { }
candidate = "." targetPath := path.Clean(path.Join(path.Dir(candidate), target))
} if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
// Ancestors of a multi-component root are resolution prefixes, not return "", errImportPathOutsideRoot
// selected targets. Complete paths and link targets remain contained. }
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") { return targetPath, nil
return "", errImportPathOutsideRoot },
} })
info, err := filesystem.Lstat(candidate)
if err != nil {
return "", err
}
if info.Mode()&fs.ModeSymlink == 0 {
resolved = candidate
continue
}
if _, exists := activeLinks[candidate]; exists {
return "", errImportDirectoryLinkCycle
}
activeLinks[candidate] = struct{}{}
target, err := filesystem.ReadLink(candidate)
if err != nil {
return "", err
}
if target == "" || strings.HasPrefix(target, "/") {
return "", errImportPathOutsideRoot
}
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
return "", errImportPathOutsideRoot
}
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
resolved = ""
}
if resolved == "" {
resolved = "."
}
return resolved, nil
} }
func isImportLinkCycle(err error) bool { func isImportLinkCycle(err error) bool {

View File

@@ -2,6 +2,7 @@ package engine
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
@@ -45,3 +46,270 @@ func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
}) })
} }
} }
func TestNativeLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
// The small graph stays within host symlink-depth limits while exceeding the
// bounded linear operation allowance without completed-target reuse.
const linkCount = 5
root := t.TempDir()
configurationRoot := filepath.Join(root, "workspace", "config")
for _, directory := range []string{
filepath.Join(configurationRoot, "graph"),
filepath.Join(configurationRoot, "selected"),
} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
for index := 0; index < linkCount; index++ {
name := fmt.Sprintf("s%02d", index)
target := "."
if index > 0 {
previous := fmt.Sprintf("s%02d", index-1)
target = previous + "/" + previous
}
if err := os.Symlink(target, filepath.Join(configurationRoot, "graph", name)); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
if err := os.WriteFile(filepath.Join(configurationRoot, "graph", "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(fmt.Sprintf("../graph/s%02d", linkCount-1), filepath.Join(configurationRoot, "selected", "entry")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
base := os.DirFS(root)
if _, ok := base.(fs.ReadLinkFS); !ok {
t.Skip("host directory filesystem does not expose link inspection")
}
filesystem := newCountingReadLinkFS(base, linkCount*20+40)
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
if _, err := access.canonical("workspace/config/selected"); err != nil {
t.Fatalf("selected directory canonicalization failed before selection: %v", err)
}
var diagnostics []Diagnostic
if selector == "directory" {
_, diagnostics = selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: true\n")
} else {
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, "workspace/config", " - glob: 'selected/*/*.yml'\n follow: true\n")
}
if len(diagnostics) != 0 {
t.Fatalf("selection diagnostics = %#v after %d link operations", diagnostics, filesystem.operations)
}
if filesystem.operations == 0 || filesystem.operations > filesystem.limit {
t.Fatalf("link-resolution operations = %d, want 1..%d", filesystem.operations, filesystem.limit)
}
if filesystem.readLinks < linkCount {
t.Fatalf("link reads = %d, want the selector to resolve the repeated dependency graph", filesystem.readLinks)
}
})
}
}
func TestNativeLinkResolutionRejectsEscapesAndSkipsWhenFollowingIsDisabled(t *testing.T) {
root := t.TempDir()
configurationRoot := filepath.Join(root, "workspace", "config")
selected := filepath.Join(configurationRoot, "selected")
if err := os.MkdirAll(selected, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../../../outside", filepath.Join(selected, "escape")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
base := os.DirFS(root)
if _, ok := base.(fs.ReadLinkFS); !ok {
t.Skip("host directory filesystem does not expose link inspection")
}
filesystem := newCountingReadLinkFS(base, 100)
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
if _, err := resolveNativeImportPath(access, filesystem.links, "workspace/config/selected/escape"); !errors.Is(err, errImportPathOutsideRoot) {
t.Fatalf("escaping native link error = %v, want outside-root rejection", err)
}
filesystem.readLinks = 0
_, diagnostics := selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: false\n")
if len(diagnostics) != 0 || filesystem.readLinks != 0 {
t.Fatalf("follow-disabled selection diagnostics=%#v readlink calls=%d", diagnostics, filesystem.readLinks)
}
}
func TestHostLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
// The small graph stays within host symlink-depth limits while exceeding the
// bounded linear operation allowance without completed-target reuse.
const linkCount = 5
root := t.TempDir()
configurationRoot := filepath.Join(root, "workspace", "config")
graph := filepath.Join(configurationRoot, "graph")
selected := filepath.Join(configurationRoot, "selected")
for _, directory := range []string{graph, selected} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
for index := 0; index < linkCount; index++ {
name := fmt.Sprintf("s%02d", index)
target := "."
if index > 0 {
previous := fmt.Sprintf("s%02d", index-1)
target = previous + "/" + previous
}
if err := os.Symlink(target, filepath.Join(graph, name)); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
if err := os.WriteFile(filepath.Join(graph, "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
t.Fatal(err)
}
aliasTarget := fmt.Sprintf("../graph/s%02d", linkCount-1)
if err := os.Symlink(aliasTarget, filepath.Join(selected, "entry")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
containedAbsolute := filepath.Join(graph, "s00")
if err := os.Symlink(containedAbsolute, filepath.Join(selected, "absolute")); err != nil {
t.Skipf("absolute symlinks unavailable: %v", err)
}
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(selected, "escape")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
access, err := openHostAccess(root)
if err != nil {
t.Fatal(err)
}
defer access.close()
name := filepath.ToSlash(filepath.Join("workspace", "config", "selected", "entry"))
operations := hostImportLinkOperations(access)
var operationCount int
lstat := operations.lstat
readlink := operations.readlink
operations.lstat = func(candidate string) (fs.FileInfo, error) {
operationCount++
if operationCount > linkCount*20+40 {
return nil, errors.New("host link operation limit exceeded")
}
return lstat(candidate)
}
operations.readlink = func(candidate string) (string, error) {
operationCount++
if operationCount > linkCount*20+40 {
return "", errors.New("host link operation limit exceeded")
}
return readlink(candidate)
}
resolved, err := resolveImportLinkPath(name, operations)
if err != nil || resolved != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
t.Fatalf("host repeated-link resolution = %q, %v", resolved, err)
}
if operationCount == 0 || operationCount > linkCount*20+40 {
t.Fatalf("host link operations = %d, want 1..%d", operationCount, linkCount*20+40)
}
contained, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "absolute"))
if err != nil || contained != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
t.Fatalf("contained absolute link = %q, %v", contained, err)
}
if _, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "escape")); !errors.Is(err, errImportPathOutsideRoot) {
t.Fatalf("escaping absolute link error = %v, want outside-root rejection", err)
}
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
var diagnostics []Diagnostic
if selector == "directory" {
_, diagnostics = selectDirectoryWithDiagnostics(t, access, root, " - dir: workspace/config/selected/entry\n follow: true\n")
} else {
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, root, " - glob: 'workspace/config/selected/entry/*.yml'\n follow: true\n")
}
if len(diagnostics) != 0 {
t.Fatalf("host selector diagnostics = %#v", diagnostics)
}
})
}
}
func TestHostLinkResolutionKeepsNamespacesIndependent(t *testing.T) {
for _, test := range []struct {
name string
target string
}{
{name: "left", target: "left-target"},
{name: "right", target: "right-target"},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, test.target), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(test.target, filepath.Join(root, "alias")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
access, err := openHostAccess(root)
if err != nil {
t.Fatal(err)
}
defer access.close()
if got, err := resolveHostImportPath(access, "alias"); err != nil || got != test.target {
t.Fatalf("namespace link = %q, %v; want %q", got, err, test.target)
}
})
}
}
type countingReadLinkFS struct {
filesystem fs.FS
links fs.ReadLinkFS
operations int
readLinks int
limit int
}
func newCountingReadLinkFS(filesystem fs.FS, limit int) *countingReadLinkFS {
links, ok := filesystem.(fs.ReadLinkFS)
if !ok {
panic("test filesystem does not support link inspection")
}
return &countingReadLinkFS{filesystem: filesystem, links: links, limit: limit}
}
func (f *countingReadLinkFS) Open(name string) (fs.File, error) { return f.filesystem.Open(name) }
func (f *countingReadLinkFS) Stat(name string) (fs.FileInfo, error) {
if statter, ok := f.filesystem.(fs.StatFS); ok {
return statter.Stat(name)
}
return fs.Stat(f.filesystem, name)
}
func (f *countingReadLinkFS) ReadDir(name string) ([]fs.DirEntry, error) {
if reader, ok := f.filesystem.(fs.ReadDirFS); ok {
return reader.ReadDir(name)
}
return fs.ReadDir(f.filesystem, name)
}
func (f *countingReadLinkFS) count() error {
f.operations++
if f.operations > f.limit {
return errors.New("native link operation limit exceeded")
}
return nil
}
func (f *countingReadLinkFS) Lstat(name string) (fs.FileInfo, error) {
if err := f.count(); err != nil {
return nil, err
}
return f.links.Lstat(name)
}
func (f *countingReadLinkFS) ReadLink(name string) (string, error) {
if err := f.count(); err != nil {
return "", err
}
f.readLinks++
return f.links.ReadLink(name)
}

View File

@@ -56,45 +56,13 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
continue continue
} }
for _, entry := range entries { for _, entry := range entries {
matched, err := path.Match(segment, entry.Name()) candidate, selected, diagnostic := prepareGlobEntry(access, basePath, parent, entry, segment, last, policy)
if err != nil { if diagnostic != nil {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")} return importSelection{}, []Diagnostic{*diagnostic}
} }
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") { if selected {
continue nextByPath[candidate.path] = candidate
} }
accessPath := access.join(parent.accessPath, entry.Name())
lexicalPath := access.join(parent.path, entry.Name())
if !access.contains(accessPath) || !access.contains(lexicalPath) {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
}
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
if err != nil {
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
}
if target.symlink && !policy.follow {
continue
}
if !last && !target.dir {
continue
}
physicalPath := target.identity
// Native source identity and relative imports use the namespace
// spelling; resolved link identities belong only to discovery.
if access.native != nil {
physicalPath = accessPath
}
if physicalPath == "" {
physicalPath, err = access.canonical(accessPath)
if err != nil {
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
}
}
candidate := selectedImportCandidate{
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
}
nextByPath[candidate.path] = candidate
} }
} }
candidates = candidatesFromMap(nextByPath) candidates = candidatesFromMap(nextByPath)
@@ -120,12 +88,53 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
// Source allowance is charged by source admission, after this filtered set // Source allowance is charged by source admission, after this filtered set
// reaches the traversal; that preserves repeated-source diagnostics first. // reaches the traversal; that preserves repeated-source diagnostics first.
selection := importSelection{candidates: selected} selection := importSelection{candidates: selected}
if len(selected) != 0 {
selection.outcome = importSelectionHasCandidates
}
return selection, nil return selection, nil
} }
func prepareGlobEntry(access *filesystemAccess, basePath string, parent selectedImportCandidate, entry fs.DirEntry, pattern string, last bool, policy resolvedImportPolicy) (selectedImportCandidate, bool, *Diagnostic) {
name := entry.Name()
matched, err := path.Match(pattern, name)
if err != nil {
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportInvalid, "import glob pattern is invalid")
}
if !matched || !policy.hidden && strings.HasPrefix(name, ".") {
return selectedImportCandidate{}, false, nil
}
accessPath := access.join(parent.accessPath, name)
lexicalPath := access.join(parent.path, name)
if !access.contains(accessPath) || !access.contains(lexicalPath) {
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")
}
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
if err != nil {
diagnostic := globCandidateDiagnostic(err, parent.site.location, "glob match could not be inspected")
return selectedImportCandidate{}, false, &diagnostic
}
if target.symlink && !policy.follow || !last && !target.dir {
return selectedImportCandidate{}, false, nil
}
physicalPath := target.identity
// Native source identity and relative imports use the namespace spelling;
// resolved link identities belong only to discovery.
if access.native != nil {
physicalPath = accessPath
}
if physicalPath == "" {
physicalPath, err = access.canonical(accessPath)
if err != nil {
diagnostic := globFilesystemDiagnostic(err, "glob match could not be canonicalized", parent.site.location)
return selectedImportCandidate{}, false, &diagnostic
}
}
return selectedImportCandidate{
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: parent.site,
}, true, nil
}
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) { func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
if !access.contains(candidatePath) || !access.contains(accessPath) { if !access.contains(candidatePath) || !access.contains(accessPath) {
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")} return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
@@ -191,7 +200,7 @@ func readGlobEntries(access *filesystemAccess, directory string, location Locati
if errors.Is(err, errImportDirectoryEntryLimit) { if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)} return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
} }
if errors.Is(err, fs.ErrNotExist) { if missingDirectoryOpen(err) {
return nil, true, nil return nil, true, nil
} }
if err != nil { if err != nil {

View File

@@ -99,7 +99,7 @@ func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -181,7 +181,7 @@ func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, ba
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics) t.Fatalf("parse diagnostics = %#v", diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("policy diagnostics = %#v", diagnostics) t.Fatalf("policy diagnostics = %#v", diagnostics)
} }

View File

@@ -216,7 +216,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
main := parseImportMain(t, mainPath) main := parseImportMain(t, mainPath)
limits := defaultImportLimits() limits := defaultImportLimits()
limits.entries = 2 limits.entries = 2
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits) request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
_, _, diagnostics := testAcquireImportWithLimits(request, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("entry-budget diagnostics = %#v", diagnostics) t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
} }
@@ -228,7 +229,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
main = parseImportMain(t, mainPath) main = parseImportMain(t, mainPath)
limits = defaultImportLimits() limits = defaultImportLimits()
limits.sources = 2 limits.sources = 2
_, _, diagnostics = loadImportedDocuments(File(mainPath), nil, main, nil, limits) request = testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
_, _, diagnostics = testAcquireImportWithLimits(request, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics) t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
} }
@@ -268,6 +270,48 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T)
} }
} }
func TestNoSourceImportsAndFilesystemContext(t *testing.T) {
t.Parallel()
directory := t.TempDir()
for _, imports := range []bool{false, true} {
name := "imports disabled"
if imports {
name = "imports enabled"
}
t.Run(name, func(t *testing.T) {
result, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{
Imports: imports, Filesystem: &FilesystemContext{Root: directory},
})
if err != nil {
t.Fatal(err)
}
if sources := result.Sources(); len(sources) != 0 {
t.Fatalf("NoSource sources = %#v", sources)
}
if _, ok := result.Metadata().Lookup(Keys("items", "root")); ok {
t.Fatal("NoSource contributed explicit input")
}
})
}
outside := t.TempDir()
for _, test := range []struct {
name string
context FilesystemContext
code Code
}{
{name: "missing root", context: FilesystemContext{BaseDir: directory}, code: CodeOptionsInvalid},
{name: "relative root", context: FilesystemContext{Root: "."}, code: CodeOptionsInvalid},
{name: "missing root directory", context: FilesystemContext{Root: filepath.Join(directory, "missing")}, code: CodeImportInvalid},
{name: "base outside root", context: FilesystemContext{BaseDir: outside, Root: directory}, code: CodeImportOutsideRoot},
} {
t.Run(test.name, func(t *testing.T) {
requireLoadCode(t, NoSource(), Options[importTestConfig]{Filesystem: &test.context}, test.code)
})
}
}
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string { func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
t.Helper() t.Helper()
requiredLine := "" requiredLine := ""

View File

@@ -40,6 +40,86 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
return true return true
} }
func (c *retainedNodeCounter) canAddClones(roots []*document.Node, copies int) bool {
if c == nil || c.limit < 0 {
return true
}
remaining := c.limit - c.count
if remaining < 0 {
return false
}
for copyIndex := 0; copyIndex < copies; copyIndex++ {
for _, root := range roots {
count, within := documentCloneNodeCount(root, remaining)
if !within {
return false
}
remaining -= count
}
}
return true
}
func documentCloneNodeCount(root *document.Node, limit int) (int, bool) {
if root == nil {
return 0, true
}
count := 0
stack := []*document.Node{root}
for len(stack) != 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if node == nil {
continue
}
count++
if count > limit {
return 0, false
}
for _, entry := range node.Mapping {
stack = append(stack, entry.Value)
}
stack = append(stack, node.Sequence...)
}
return count, true
}
func addInspectionHistory(counter *retainedNodeCounter, inspection *Inspection) bool {
if inspection == nil {
return false
}
for _, root := range []*document.Node{inspection.baseline, inspection.input, inspection.final, inspection.preCLI} {
if !counter.add(root) {
return false
}
}
for _, application := range inspection.applications {
if !counter.add(application.value) {
return false
}
}
for _, application := range inspection.postDecodeApps {
if !counter.add(application.value) {
return false
}
}
for _, snapshots := range [][]resolutionSnapshot{inspection.resolution, inspection.resolutionReplay} {
for _, snapshot := range snapshots {
if !counter.add(snapshot.before) || !counter.add(snapshot.after) {
return false
}
}
}
for _, operations := range [][]inheritanceOperation{inspection.inheritance, inspection.metadata.inheritance} {
for _, operation := range operations {
if !counter.add(operation.parent) || !counter.add(operation.effective) {
return false
}
}
}
return true
}
// History applications borrow immutable policy-validated input trees. Metadata // History applications borrow immutable policy-validated input trees. Metadata
// records answer presence and origin queries; replay reconstructs value changes. // records answer presence and origin queries; replay reconstructs value changes.
// Replay reconciles with canonical decoding, applies supplied CLI contributions, // Replay reconciles with canonical decoding, applies supplied CLI contributions,
@@ -237,10 +317,6 @@ func applyHistoryApplication(root **document.Node, application historyApplicatio
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath) mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
} }
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
}
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) { func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
if incoming == nil { if incoming == nil {
return return
@@ -337,6 +413,27 @@ func applySequenceHistory(target **document.Node, incoming *document.Node, contr
} }
} }
func sequenceHistoryContributionForCombination(
destination Path,
operation sequenceMode,
incoming *document.Node,
combined sequenceCombinationResult,
) sequenceHistoryContribution {
contribution := sequenceHistoryContribution{
path: destination.clone(), mode: sequenceHistoryMapped,
}
if operation == sequenceModeReplace {
contribution.mode = sequenceHistoryReplace
if incoming != nil && incoming.Kind == document.Sequence {
contribution.length = len(incoming.Sequence)
}
return contribution
}
contribution.items = append([]sequenceItemProjection(nil), combined.higherItems...)
contribution.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
return contribution
}
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution { func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
if values == nil { if values == nil {
return nil return nil
@@ -503,66 +600,3 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
} }
} }
} }
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...)
}
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
seen := make(map[*document.Node]bool)
count := 0
var visit func(*document.Node) bool
visit = func(node *document.Node) bool {
if node == nil || seen[node] {
return true
}
seen[node] = true
count++
if count > limit {
return false
}
for _, entry := range node.Mapping {
if !visit(entry.Value) {
return false
}
}
for _, child := range node.Sequence {
if !visit(child) {
return false
}
}
return true
}
if !visit(baseline) || !visit(input) || !visit(final) {
return false
}
for _, application := range applications {
if !visit(application.value) {
return false
}
}
for _, resolution := range resolutions {
if !visit(resolution.before) || !visit(resolution.after) {
return false
}
}
for _, expansion := range inherited {
if expansion == nil {
continue
}
if !visit(expansion.root) {
return false
}
for _, operation := range expansion.operations {
if !visit(operation.parent) || !visit(operation.effective) {
return false
}
}
for _, replacement := range expansion.replacements {
if !visit(replacement.before) || !visit(replacement.after) {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,210 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type historyBudgetSimpleConfig struct {
Value string
}
type historyBudgetRecord struct {
Value string
Credential Secret
}
type historyBudgetLayeredConfig struct {
Parent historyBudgetRecord
Child historyBudgetRecord
}
func retainedInspectionNodeCountForTest(inspection *Inspection) int {
seen := make(map[*document.Node]struct{})
stack := []*document.Node{inspection.baseline, inspection.input, inspection.final, inspection.preCLI}
for _, application := range inspection.applications {
stack = append(stack, application.value)
}
for _, application := range inspection.postDecodeApps {
stack = append(stack, application.value)
}
for _, snapshot := range inspection.resolution {
stack = append(stack, snapshot.before, snapshot.after)
}
for _, snapshot := range inspection.resolutionReplay {
stack = append(stack, snapshot.before, snapshot.after)
}
for _, operation := range inspection.inheritance {
stack = append(stack, operation.parent, operation.effective)
}
for _, operation := range inspection.metadata.inheritance {
stack = append(stack, operation.parent, operation.effective)
}
for len(stack) != 0 {
last := len(stack) - 1
node := stack[last]
stack = stack[:last]
if node == nil {
continue
}
if _, exists := seen[node]; exists {
continue
}
seen[node] = struct{}{}
for _, entry := range node.Mapping {
stack = append(stack, entry.Value)
}
stack = append(stack, node.Sequence...)
}
return len(seen)
}
func TestLoadHistoryBudgetUsesExactRetainedNodeCount(t *testing.T) {
load := func(historyLimit int, validations *int) (*Loaded[historyBudgetSimpleConfig], error) {
return loadWithSecretLookupLimit(
Bytes("main", []byte("value: retained\n")),
testSecretBinding(),
func(string) (string, bool) { return "", false },
historyLimit,
Options[historyBudgetSimpleConfig]{Definition: Definition[historyBudgetSimpleConfig]{
Validate: func(*historyBudgetSimpleConfig, *Validation) { *validations++ },
}},
)
}
var baselineValidations int
baseline, err := load(10000, &baselineValidations)
if err != nil {
t.Fatalf("baseline load: %v", err)
}
want := retainedInspectionNodeCountForTest(baseline.Inspection)
if want == 0 {
t.Fatal("independent retained-tree count was empty")
}
var exactValidations int
loaded, err := load(want, &exactValidations)
if err != nil || loaded == nil {
t.Fatalf("load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
}
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
t.Fatalf("retained node count = %d, want %d", got, want)
}
if exactValidations != 1 {
t.Fatalf("validation calls at exact count = %d, want 1", exactValidations)
}
var rejectedValidations int
loaded, err = load(want-1, &rejectedValidations)
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
t.Fatalf("load below retained-node count: result=%v error=%v", loaded, err)
}
if rejectedValidations != 0 {
t.Fatalf("validation ran before history rejection: %d calls", rejectedValidations)
}
}
func TestLayeredHistoryBudgetRejectsBeforeSecretsAndValidation(t *testing.T) {
type callbackCounts struct {
lookups int
validations int
}
makeRequest := func(counts *callbackCounts) loadRequest[historyBudgetLayeredConfig] {
return loadRequest[historyBudgetLayeredConfig]{
main: loadInput{source: Bytes("main", []byte("child:\n extends: parent\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("underlay", []byte(
"parent:\n value: inherited\n credential: HISTORY_BUDGET_SECRET\n",
))}}}},
overrides: []Override{{Path: Keys("child", "value"), Value: "cli", Flag: "--child-value"}},
definition: Definition[historyBudgetLayeredConfig]{
Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error {
configuration.Child.Value += "-resolved"
resolution.Record(Keys("child", "value"), ChangeNormalization, "normalize child")
return nil
},
Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ },
},
}
}
lookup := func(counts *callbackCounts) secretLookup {
return func(name string) (string, bool) {
counts.lookups++
if name != "HISTORY_BUDGET_SECRET" {
t.Errorf("secret lookup name = %q", name)
}
return "resolved-secret", true
}
}
baselineCounts := &callbackCounts{}
baseline, err := loadGroupedWithSecretLookupLimit(makeRequest(baselineCounts), testSecretBinding(), lookup(baselineCounts), 10000)
if err != nil {
t.Fatalf("baseline grouped load: %v", err)
}
want := retainedInspectionNodeCountForTest(baseline.Inspection)
if want == 0 {
t.Fatal("independent retained-tree count was empty")
}
if len(baseline.Inspection.inheritance) == 0 || len(baseline.Inspection.metadata.inheritance) == 0 {
t.Fatal("layered load did not retain both inheritance evidence copies")
}
exactCounts := &callbackCounts{}
loaded, err := loadGroupedWithSecretLookupLimit(makeRequest(exactCounts), testSecretBinding(), lookup(exactCounts), want)
if err != nil || loaded == nil {
t.Fatalf("grouped load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
}
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
t.Fatalf("grouped retained node count = %d, want %d", got, want)
}
if loaded.Config.Child.Value != "cli-resolved" || exactCounts.lookups != 1 || exactCounts.validations != 1 {
t.Fatalf("grouped lifecycle result=%#v callbacks=%#v", loaded.Config, exactCounts)
}
rejectedCounts := &callbackCounts{}
loaded, err = loadGroupedWithSecretLookupLimit(makeRequest(rejectedCounts), testSecretBinding(), lookup(rejectedCounts), want-1)
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
t.Fatalf("grouped load below retained-node count: result=%v error=%v", loaded, err)
}
if rejectedCounts.lookups != 0 || rejectedCounts.validations != 0 {
t.Fatalf("callbacks ran before history rejection: %#v", rejectedCounts)
}
}
func TestPreparedInheritanceEvidenceOwnsDistinctTrees(t *testing.T) {
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "shared"}}}}
resolved := resolvedLoad[historyBudgetSimpleConfig]{
inheritance: &inheritanceScope{operations: []inheritanceOperation{{parent: root, effective: root}}},
}
counter := newRetainedNodeCounter(8)
inspection, ok := prepareInspectionHistory(resolved, counter)
if !ok {
t.Fatal("preparation rejected the exact size of the distinct retained copies")
}
if len(inspection.inheritance) != 1 || len(inspection.metadata.inheritance) != 1 {
t.Fatalf("inheritance copies = inspection %#v, metadata %#v", inspection.inheritance, inspection.metadata.inheritance)
}
inspectionOperation := inspection.inheritance[0]
metadataOperation := inspection.metadata.inheritance[0]
if inspectionOperation.parent == metadataOperation.parent || inspectionOperation.effective == metadataOperation.effective || counter.count != 8 {
t.Fatalf("separate inheritance copies were not counted independently: inspection=(%p, %p) metadata=(%p, %p) count=%d", inspectionOperation.parent, inspectionOperation.effective, metadataOperation.parent, metadataOperation.effective, counter.count)
}
}
func hasHistoryLimitDiagnostic(t *testing.T, err error) bool {
t.Helper()
if err == nil {
return false
}
loadErr, ok := err.(*LoadError)
if !ok {
t.Fatalf("error type = %T, want *LoadError", err)
}
for _, diagnostic := range loadErr.Diagnostics() {
if diagnostic.Code == CodeExplainLimitExceeded {
return true
}
}
return false
}

View File

@@ -11,17 +11,18 @@ import (
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) { func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{ policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}, {selector: sequenceIdentitySelector{
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}}, literalSequenceSelector("collections"), anyMappingSequenceSelector(), literalSequenceSelector("records"),
}, identityFields: []string{"id"}},
}) })
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n") first := parseCompositionDocument(t, "source-1", "collections: {north: {records: [{id: a, value: first}]}, south: {records: [{id: x}]}}\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n") second := parseCompositionDocument(t, "source-2", "collections: {north: {records: [{id: b, value: second}]}, south: {records: [{id: y}]}}\n")
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second) composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("compose diagnostics = %#v", diagnostics) t.Fatalf("compose diagnostics = %#v", diagnostics)
} }
for applicationIndex, application := range composed.applications { for applicationIndex, application := range composed.applications {
for path, want := range map[string]int{"/records": 1, "/other": 1} { for path, want := range map[string]int{"/collections/north/records": 1, "/collections/south/records": 1} {
sequence, present := documentAtPath(application.value, mustParsePath(t, path)) sequence, present := documentAtPath(application.value, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want { if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want {
t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present) t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present)
@@ -29,14 +30,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
} }
} }
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nother: [{id: default}]\n") working := parseCompositionDocument(t, "defaults", "collections: {north: {records: [{id: default}]}, south: {records: [{id: default}]}}\n")
index := new(mappingIndex) index := new(mappingIndex)
for _, application := range composed.applications { for _, application := range composed.applications {
applyHistoryApplication(&working, application, index) applyHistoryApplication(&working, application, index)
} }
for path, want := range map[string][]string{ for path, want := range map[string][]string{
"/records": {"a", "b"}, "/collections/north/records": {"a", "b"},
"/other": {"x", "y"}, "/collections/south/records": {"x", "y"},
} { } {
sequence, present := documentAtPath(working, mustParsePath(t, path)) sequence, present := documentAtPath(working, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) { if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
@@ -53,14 +54,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
application := composed.applications[1] application := composed.applications[1]
cloned := cloneHistoryApplications(composed.applications) cloned := cloneHistoryApplications(composed.applications)
cloned[1].sequences[0].path.segments[0].key = "mutated" cloned[1].sequences[0].path.segments[0].key = "mutated"
if composed.applications[1].sequences[0].path.String() != "/records" { if composed.applications[1].sequences[0].path.String() != "/collections/north/records" {
t.Fatal("cloned sequence history path aliases its source") t.Fatal("cloned sequence history path aliases its source")
} }
addedPath := Keys("records").Index(1).Key("value") addedPath := Keys("collections", "north", "records").Index(1).Key("value")
if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() { if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() {
t.Fatalf("appended value operation = %s, %v", operation.String(), relevant) t.Fatalf("appended value operation = %s, %v", operation.String(), relevant)
} }
if _, relevant := applicationOperationPath(application, Keys("records").Index(0).Key("value"), nil); relevant { if _, relevant := applicationOperationPath(application, Keys("collections", "north", "records").Index(0).Key("value"), nil); relevant {
t.Fatal("later contribution was reported as changing an earlier record") t.Fatal("later contribution was reported as changing an earlier record")
} }
origin := historyApplicationOrigin(application, addedPath, nil) origin := historyApplicationOrigin(application, addedPath, nil)
@@ -69,10 +70,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
} }
overlay := historyApplication{ overlay := historyApplication{
layer: LayerOverlay, layer: LayerOverlay,
value: parseCompositionDocument(t, "overlay", "records: []\n"), value: parseCompositionDocument(t, "overlay", "collections: {north: {records: []}}\n"),
} }
applyHistoryApplication(&working, overlay, index) applyHistoryApplication(&working, overlay, index)
replaced, present := documentAtPath(working, Keys("records")) replaced, present := documentAtPath(working, Keys("collections", "north", "records"))
if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 { if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 {
t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present) t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present)
} }
@@ -469,21 +470,18 @@ func TestSequenceHistoryReplaysCombinationEvidence(t *testing.T) {
t.Fatalf("combine diagnostics = %#v", diagnostics) t.Fatalf("combine diagnostics = %#v", diagnostics)
} }
mode := sequenceHistoryMapped
if test.operation == sequenceModeReplace {
mode = sequenceHistoryReplace
}
length := 0
if mode == sequenceHistoryReplace {
length = len(combined.higherItems)
}
application := historyApplication{ application := historyApplication{
layer: LayerOverlay, layer: LayerOverlay,
value: higherRoot, value: higherRoot,
sequences: []sequenceHistoryContribution{{ sequences: []sequenceHistoryContribution{
path: Keys("records"), mode: mode, length: length, sequenceHistoryContributionForCombination(Keys("records"), test.operation, higher, combined),
items: combined.higherItems, replacements: combined.replacements, },
}}, }
if len(combined.higherItems) != 0 {
combined.higherItems[0].outputIndex++
}
if len(combined.replacements) != 0 {
combined.replacements[0].outputIndex++
} }
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences) application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
replayed := document.Clone(lowerRoot) replayed := document.Clone(lowerRoot)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func prependCompositionDiagnosticPaths(diagnostics []Diagnostic, prefix Path) []Diagnostic {
cloned := append([]Diagnostic(nil), diagnostics...)
for index := range cloned {
if !isCompositionDiagnostic(cloned[index]) {
continue
}
cloned[index].Path = appendDestination(prefix, cloned[index].Path)
cloned[index].Primary = cloneLocation(cloned[index].Primary)
cloned[index].Related = cloneLocations(cloned[index].Related)
}
return cloned
}
func isCompositionDiagnostic(diagnostic Diagnostic) bool {
return diagnostic.Code == CodeComposeConflict || diagnostic.Code == CodeComposeInvalid
}
// On a composition failure only the earlier contributions are needed to locate
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
// and access diagnostics keep their original source-local coordinates.
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path, beforeContribution int) []Diagnostic {
for _, diagnostic := range diagnostics {
if isCompositionDiagnostic(diagnostic) {
prefix := parent
prefix.contributions = parent.contributions[:beforeContribution]
index := priorSequenceLength([]acquisitionScope{prefix}, destination)
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
}
}
return diagnostics
}
// This scan is confined to failed loads, not the successful construction path.
func priorSequenceLength(scopes []acquisitionScope, path Path) int {
length := 0
for _, scope := range scopes {
for _, contribution := range scope.contributions {
if node, present := documentAtPath(contribution.document, path); present && node.Kind == document.Sequence {
length += len(node.Sequence)
}
}
}
return length
}
func projectPriorInputDiagnosticPaths(diagnostics []Diagnostic, scopes []acquisitionScope) []Diagnostic {
cloned := cloneDiagnostics(diagnostics)
for index := range cloned {
if !isCompositionDiagnostic(cloned[index]) {
continue
}
for position, segment := range cloned[index].Path.segments {
if segment.kind != pathSegmentIndex {
continue
}
container := Path{segments: cloned[index].Path.segments[:position]}
cloned[index].Path.segments[position].index += priorSequenceLength(scopes, container)
// Registration does not address paths through item indexes. A prior
// input shifts the outer collection, not its item-local sequences.
break
}
}
return cloned
}
func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
if composed == nil {
return
}
applications := composed.applications
projectedApplications := make([]historyApplication, 0, len(applications))
ranges := indexSequenceRanges(composed.sequenceRanges)
for sourceIndex, contribution := range scope.contributions {
if contribution.nested == nil {
projectedApplications = append(projectedApplications, applications[sourceIndex])
continue
}
itemIndex := 0
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found {
if projected, ok := assigned.outputForInput(0); ok {
itemIndex = projected
}
}
prefix := contribution.destination.Index(itemIndex)
if sourceIndex < len(applications) {
outer := applications[sourceIndex]
structural := document.Clone(outer.value)
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > 0 {
// The retained fragment is still a local singleton. Its assigned
// outer index belongs to the projection, not this source tree.
node.Sequence[0] = &document.Node{Kind: document.Mapping, Location: node.Sequence[0].Location}
}
outer.value = structural
projectedApplications = append(projectedApplications, outer)
for _, nested := range contribution.nested.applications {
projected := nested
projected.path = appendDestination(prefix, nested.path)
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
projected.sequencesByPath = indexSequenceHistoryContributions(projected.sequences)
projectedApplications = append(projectedApplications, projected)
}
}
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
}
composed.applications = projectedApplications
projectNestedCollectionPolicies(composed, scope, ranges)
}
func projectNestedCollectionPolicies(composed *composedDocument, scope acquisitionScope, ranges map[sequenceRangeKey]sequenceRange) {
if composed == nil {
return
}
type itemPolicyProjection struct {
destination Path
policy collectionPolicy
base int
}
projected := make(map[string]*itemPolicyProjection)
var prefixLengths map[string]int
for sourceIndex, contribution := range scope.contributions {
if prefixLengths != nil && sourceIndex > 0 {
addSequencePrefixLengths(scope.contributions[sourceIndex-1].document, Path{}, prefixLengths)
}
if contribution.nested == nil || len(contribution.nested.collectionPolicies.collections) == 0 {
continue
}
key := contribution.destination.String()
assigned := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: key}]
itemIndex, found := assigned.outputForInput(0)
if !found {
// Normal composition supplies the assignment directly. For a
// handoff without ranges, scan each preceding tree only once.
if prefixLengths == nil {
prefixLengths = make(map[string]int)
for _, previous := range scope.contributions[:sourceIndex] {
addSequencePrefixLengths(previous.document, Path{}, prefixLengths)
}
}
itemIndex = prefixLengths[key]
}
working, initialized := projected[key]
if !initialized {
// Read one owned copy per destination, then add each incoming
// record without recopying earlier records and their scopes.
policy, _ := composed.collectionPolicies.at(contribution.destination)
working = &itemPolicyProjection{destination: contribution.destination, policy: policy, base: len(policy.recordOrder)}
projected[key] = working
}
working.policy.setRecordPolicyAt(working.base+itemIndex, collectionRecordPolicy{
owner: newCollectionRecordPolicy().owner,
scope: contribution.nested.collectionPolicies,
})
}
for _, working := range projected {
composed.collectionPolicies.set(working.destination, working.policy)
}
}
func addSequencePrefixLengths(node *document.Node, path Path, lengths map[string]int) {
if node == nil {
return
}
if node.Kind == document.Sequence {
lengths[path.String()] += len(node.Sequence)
return
}
// Contribution destinations select mapping paths, not sequence members.
for _, entry := range node.Mapping {
addSequencePrefixLengths(entry.Value, path.Key(entry.Key), lengths)
}
}
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
var visit func(*document.Node, Path)
visit = func(node *document.Node, path Path) {
if node == nil {
return
}
if origin, ok := origins[path.String()]; ok {
target[appendDestination(prefix, path).String()] = cloneOrigin(origin)
}
for _, entry := range node.Mapping {
visit(entry.Value, path.Key(entry.Key))
}
for index, child := range node.Sequence {
visit(child, path.Index(index))
}
}
visit(root, Path{})
}
func projectAcquiredDestinations(composed *composedDocument, scope acquisitionScope, sources []SourceInfo) {
byID := make(map[string]int, len(sources))
for index := range sources {
byID[sources[index].ID] = index
}
var projectScope func(*composedDocument, acquisitionScope, Path, bool, string)
projectScope = func(currentComposition *composedDocument, current acquisitionScope, prefix Path, insideItem bool, parentSourceID string) {
ranges := indexSequenceRanges(currentComposition.sequenceRanges)
for sourceIndex, contribution := range current.contributions {
if contribution.source.ID == parentSourceID {
continue
}
contributionPrefix := prefix
contributionInsideItem := insideItem
itemIndex := 0
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found {
if projected, ok := assigned.outputForInput(0); ok {
itemIndex = projected
}
}
if contribution.source.placementMode == PlacementModeSequenceItem {
container := appendDestination(contributionPrefix, contribution.destination)
contribution.source.Destination = container.clone()
contribution.source.destinationUnresolved = false
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
if position, ok := byID[contribution.source.ID]; ok {
sources[position].Destination = container
sources[position].destinationUnresolved = false
}
contributionPrefix = appendDestination(container, Path{}.Index(itemIndex))
contributionInsideItem = true
projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex)
} else if contributionInsideItem {
exactDestination := appendDestination(contributionPrefix, contribution.destination)
contribution.source.Destination = exactDestination.clone()
contribution.source.destinationUnresolved = false
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
if position, ok := byID[contribution.source.ID]; ok {
sources[position].Destination = exactDestination
sources[position].destinationUnresolved = false
}
projectSourceVersionEvidence(contribution.processing, exactDestination, nil, sourceIndex)
} else {
projectSourceVersionEvidence(contribution.processing, contribution.destination, ranges, sourceIndex)
}
if contribution.nested != nil && contribution.nested.acquisition != nil {
for _, nestedScope := range contribution.nested.acquisition {
projectScope(contribution.nested, nestedScope, contributionPrefix, contributionInsideItem, contribution.source.ID)
}
}
}
}
projectScope(composed, scope, Path{}, false, "")
}
func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path, ranges sequenceRangeIndex, sourceIndex int) {
if processing != nil && processing.evidence != nil {
evidence := processing.evidence
evidence.destination = destination.clone()
// Rebuild from source-local paths on each handoff: an input can be
// composed first on its own and then at a different offset in its tier.
evidence.projectedPaths = nil
paths := evidence.sensitivityPaths()
for index, path := range paths {
for position := range path.segments {
prefix := Path{segments: path.segments[:position]}
assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: prefix.String()}]
if !found {
continue
}
item, ok := schemaIndex(path.segments[position])
if ok && path.segments[position].kind != pathSegmentKey {
if outputIndex, projected := assigned.outputForInput(item); projected {
path.segments[position] = pathSegment{kind: pathSegmentIndex, index: outputIndex}
}
}
}
paths[index] = path
}
evidence.projectedPaths = paths
}
}

View File

@@ -0,0 +1,108 @@
package engine
import (
"errors"
"os"
"path/filepath"
"testing"
"testing/fstest"
)
func TestImportedAcquisitionClosesOwnedHostRootOnEveryExit(t *testing.T) {
tests := []struct {
name string
main string
child string
version bool
wantError bool
wantPanic bool
}{
{name: "success", main: "configkit: {imports: [{path: child.yml}]}\n", child: "value: child\n"},
{name: "error", main: "configkit: {imports: [{path: missing.yml, required: true}]}\n", wantError: true},
{name: "version panic", main: "configkit: {imports: [{path: child.yml}]}\n", child: "version: v1\n", version: true, wantPanic: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rootPath := t.TempDir()
mainPath := filepath.Join(rootPath, "main.yml")
if err := os.WriteFile(mainPath, []byte(test.main), 0o600); err != nil {
t.Fatal(err)
}
if test.child != "" {
if err := os.WriteFile(filepath.Join(rootPath, "child.yml"), []byte(test.child), 0o600); err != nil {
t.Fatal(err)
}
}
source := File(mainPath)
main, diagnostics := parseSource(source, "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("main parse diagnostics = %#v", diagnostics)
}
access, err := openHostAccess(rootPath)
if err != nil {
t.Fatal(err)
}
ownedRoot := access.root
sentinel := errors.New("imported version checker panic sentinel")
var rule *VersionRule
var versionState *sourceVersionState
if test.version {
rule = &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { panic(sentinel) }}
versionState = &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
}
request := testImportRequest(source, nil, main, Path{}, rule, nil)
policy := &acquisitionPolicy{importLimits: defaultImportLimits()}
state := &acquisitionState{accounting: &importAccounting{}, versionState: versionState}
tier := &tierAcquisitionState{}
resolved := resolvedFilesystemContext{rootPath: rootPath, basePath: rootPath, mainPath: mainPath}
var resultDiagnostics []Diagnostic
var recovered any
func() {
defer func() { recovered = recover() }()
_, _, resultDiagnostics = acquireImportedWithAccess(request, policy, state, tier, access, resolved)
}()
if test.wantPanic {
if recovered != sentinel {
t.Fatalf("recovered panic = %#v, want original sentinel", recovered)
}
} else if recovered != nil {
t.Fatalf("unexpected panic = %#v", recovered)
}
if test.wantError != (len(resultDiagnostics) != 0) {
t.Fatalf("diagnostics = %#v, want error=%v", resultDiagnostics, test.wantError)
}
if _, err := ownedRoot.Stat("."); err == nil {
t.Fatal("owned host root remained usable after acquisition returned")
}
})
}
}
func TestImportedAcquisitionClosesChildFileBeforeVersionPanic(t *testing.T) {
filesystem := &observedFilesystem{files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}]}\n")},
"child.yml": &fstest.MapFile{Data: []byte("version: v1\n")},
}}
namespace := newNativeNamespace("bundle", filesystem)
source := filesystemFile(namespace, "main.yml")
main, diagnostics := parseSource(source, "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("main parse diagnostics = %#v", diagnostics)
}
sentinel := errors.New("imported version checker panic sentinel")
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { panic(sentinel) }}
versionState := &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
request := testImportRequest(source, nil, main, Path{}, rule, nil)
access := newNativeAccess(filesystem, ".", namespace.token)
var recovered any
func() {
defer func() { recovered = recover() }()
_, _, _ = acquireImportedWithAccess(request, &acquisitionPolicy{importLimits: defaultImportLimits()},
&acquisitionState{accounting: &importAccounting{}, versionState: versionState}, &tierAcquisitionState{}, access,
resolvedFilesystemContext{native: namespace, rootPath: ".", basePath: ".", mainPath: source.path})
}()
if recovered != sentinel {
t.Fatalf("recovered panic = %#v, want original sentinel", recovered)
}
filesystem.assertClosed(t)
}

View File

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

View File

@@ -26,7 +26,6 @@ type selectedImportCandidate struct {
// importSelection contains lexical candidates only; admission assigns source identity later. // importSelection contains lexical candidates only; admission assigns source identity later.
type importSelection struct { type importSelection struct {
candidates []selectedImportCandidate candidates []selectedImportCandidate
outcome importSelectionOutcome
} }
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) { func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
@@ -68,9 +67,6 @@ func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec
candidates = append(candidates, candidate) candidates = append(candidates, candidate)
} }
selection := importSelection{candidates: candidates} selection := importSelection{candidates: candidates}
if len(candidates) != 0 {
selection.outcome = importSelectionHasCandidates
}
return selection, nil return selection, nil
} }
@@ -139,72 +135,23 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
} }
entry := frame.entries[frame.next] entry := frame.entries[frame.next]
frame.next++ frame.next++
accessPath := access.join(frame.directory.accessPath, entry.Name()) result, diagnostic := prepareImportDirectoryEntry(access, basePath, *frame, entry, policy, limits, accounting, activeDirectories)
lexicalPath := access.join(frame.directory.path, entry.Name()) if diagnostic != nil {
candidate := selectedImportCandidate{ return importSelection{}, []Diagnostic{*diagnostic}
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
} }
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") { switch result.kind {
case importDirectoryEntrySkip:
continue continue
} case importDirectoryEntryFile:
target, entryErr := access.importEntryTarget(candidate.accessPath, entry, policy.follow) if _, exists := seenCandidates[result.candidate.path]; exists {
if entryErr != nil {
if errors.Is(entryErr, fs.ErrNotExist) {
continue continue
} }
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(frame.directory.site, entryErr)} seenCandidates[result.candidate.path] = struct{}{}
discovered = append(discovered, result.candidate)
case importDirectoryEntryDirectory:
activeDirectories[result.frame.identity] = struct{}{}
stack = append(stack, result.frame)
} }
if target.symlink && !policy.follow {
continue
}
if target.dir {
if !directoryDepthAllows(policy, frame.depth) {
continue
}
if _, exists := activeDirectories[target.identity]; exists {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")}
}
childMissing, childIsDirectory, childDiagnostic := validateNamedImportTarget(access, candidate, false, true)
if childDiagnostic != nil {
return importSelection{}, []Diagnostic{*childDiagnostic}
}
if childMissing || !childIsDirectory {
continue
}
childEntries, vanished, childDiagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
if childDiagnostic != nil {
return importSelection{}, []Diagnostic{*childDiagnostic}
}
if !vanished {
if access.native == nil {
candidate.accessPath = target.identity
}
activeDirectories[target.identity] = struct{}{}
stack = append(stack, importDirectoryFrame{directory: candidate, depth: frame.depth + 1, entries: childEntries, identity: target.identity})
}
continue
}
if target.identity != "" && access.native == nil {
candidate.accessPath = target.identity
} else {
canonical, canonicalErr := access.canonical(accessPath)
if canonicalErr != nil {
if errors.Is(canonicalErr, fs.ErrNotExist) {
continue
}
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(canonicalErr, "directory search file could not be canonicalized", frame.directory.site.location)}
}
candidate.accessPath = canonical
}
if !matchesImportFilename(entry.Name(), policy.pattern) {
continue
}
if _, exists := seenCandidates[candidate.path]; exists {
continue
}
seenCandidates[candidate.path] = struct{}{}
discovered = append(discovered, candidate)
} }
} }
sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path }) sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path })
@@ -223,12 +170,76 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")} return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
} }
selection := importSelection{candidates: selected} selection := importSelection{candidates: selected}
if len(selected) != 0 {
selection.outcome = importSelectionHasCandidates
}
return selection, nil return selection, nil
} }
func prepareImportDirectoryEntry(access *filesystemAccess, basePath string, frame importDirectoryFrame, entry fs.DirEntry, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting, activeDirectories map[string]struct{}) (importDirectoryEntryResult, *Diagnostic) {
accessPath := access.join(frame.directory.accessPath, entry.Name())
lexicalPath := access.join(frame.directory.path, entry.Name())
candidate := selectedImportCandidate{
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
}
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
target, err := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
return importDirectoryEntryResult{}, importDirectoryTargetDiagnostic(frame.directory.site, err)
}
if target.symlink && !policy.follow {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
if target.dir {
if !directoryDepthAllows(policy, frame.depth) {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
if _, exists := activeDirectories[target.identity]; exists {
return importDirectoryEntryResult{}, importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")
}
missing, isDirectory, diagnostic := validateNamedImportTarget(access, candidate, false, true)
if diagnostic != nil {
return importDirectoryEntryResult{}, diagnostic
}
if missing || !isDirectory {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
entries, vanished, diagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
if diagnostic != nil {
return importDirectoryEntryResult{}, diagnostic
}
if vanished {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
if access.native == nil {
candidate.accessPath = target.identity
}
return importDirectoryEntryResult{kind: importDirectoryEntryDirectory, frame: importDirectoryFrame{
directory: candidate, depth: frame.depth + 1, entries: entries, identity: target.identity,
}}, nil
}
if target.identity != "" && access.native == nil {
candidate.accessPath = target.identity
} else {
canonical, err := access.canonical(accessPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
diagnostic := globFilesystemDiagnostic(err, "directory search file could not be canonicalized", frame.directory.site.location)
return importDirectoryEntryResult{}, &diagnostic
}
candidate.accessPath = canonical
}
if !matchesImportFilename(entry.Name(), policy.pattern) {
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
}
return importDirectoryEntryResult{kind: importDirectoryEntryFile, candidate: candidate}, nil
}
type importDirectoryFrame struct { type importDirectoryFrame struct {
directory selectedImportCandidate directory selectedImportCandidate
depth int depth int
@@ -237,6 +248,20 @@ type importDirectoryFrame struct {
identity string identity string
} }
type importDirectoryEntryKind uint8
const (
importDirectoryEntrySkip importDirectoryEntryKind = iota
importDirectoryEntryFile
importDirectoryEntryDirectory
)
type importDirectoryEntryResult struct {
kind importDirectoryEntryKind
candidate selectedImportCandidate
frame importDirectoryFrame
}
func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic { func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic {
if isImportLinkCycle(err) { if isImportLinkCycle(err) {
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected") return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")
@@ -267,7 +292,7 @@ func importDirectoryEntries(access *filesystemAccess, directory string, site imp
if errors.Is(err, errImportDirectoryEntryLimit) { if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit") return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
} }
if errors.Is(err, fs.ErrNotExist) { if missingDirectoryOpen(err) {
return nil, true, nil return nil, true, nil
} }
if err != nil { if err != nil {

View File

@@ -30,7 +30,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("optional partial selection diagnostics = %#v", diagnostics) t.Fatalf("optional partial selection diagnostics = %#v", diagnostics)
} }
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" { if len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" {
t.Fatalf("partial selection = %#v", selection) t.Fatalf("partial selection = %#v", selection)
} }
@@ -48,7 +48,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true}) empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true})
if len(diagnostics) != 0 || empty.outcome != importSelectionNone || len(empty.candidates) != 0 { if len(diagnostics) != 0 || len(empty.candidates) != 0 {
t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics) t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics)
} }
} }
@@ -67,7 +67,7 @@ func TestSelectNamedImportsChecksRequiredTargetsBeforeExclusions(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -118,12 +118,12 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy) selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy)
if len(diagnostics) != 0 || selection.outcome != importSelectionNone { if len(diagnostics) != 0 || len(selection.candidates) != 0 {
t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics) t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics)
} }
if filesystem.openCalls != 0 || filesystem.statCalls == 0 { if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
@@ -135,7 +135,7 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
hidden := false hidden := false
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], importDefaults{hidden: &hidden}) privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], ImportDefaults{Hidden: &hidden})
if len(policyDiagnostics) != 0 || !privatePolicy.hidden { if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics) t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics)
} }
@@ -161,8 +161,21 @@ func TestSelectNamedImportsChecksNormalizedPathsLexically(t *testing.T) {
if len(diagnostics) != 0 || len(selection.candidates) != 2 { if len(diagnostics) != 0 || len(selection.candidates) != 2 {
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics) t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
} }
if strings.Join(filesystem.statPaths, ",") != "a.yaml,a.yaml,z.yaml,z.yaml" { if selection.candidates[0].path != "a.yaml" || selection.candidates[1].path != "z.yaml" {
t.Fatalf("target stat order = %#v", filesystem.statPaths) t.Fatalf("candidate order = %#v, want a.yaml then z.yaml", selection.candidates)
}
failingFilesystem := &statOnlyImportFilesystem{statErrors: map[string]error{
"a.yaml": fs.ErrPermission,
"z.yaml": fs.ErrPermission,
}}
failingAccess := newNativeAccess(failingFilesystem, ".", &namespaceToken{})
_, diagnostics = selectNamedImportCandidates(failingAccess, ".", specs[0], resolvedImportPolicy{})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != "/configkit/imports/0/path/1" {
t.Fatalf("first lexical failure = %#v, want /configkit/imports/0/path/1", diagnostics)
}
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" {
t.Fatalf("first lexical failure location = %#v", diagnostics[0].Primary)
} }
} }
@@ -226,7 +239,6 @@ type statOnlyImportFilesystem struct {
statErrors map[string]error statErrors map[string]error
openCalls int openCalls int
statCalls int statCalls int
statPaths []string
} }
func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) { func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
@@ -236,7 +248,6 @@ func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) { func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) {
filesystem.statCalls++ filesystem.statCalls++
filesystem.statPaths = append(filesystem.statPaths, name)
if err := filesystem.statErrors[name]; err != nil { if err := filesystem.statErrors[name]; err != nil {
return nil, err return nil, err
} }

View File

@@ -35,23 +35,13 @@ type importDeclarationSpec struct {
pattern []string pattern []string
patternSet bool patternSet bool
exclude []string exclude []string
excludeSet bool
depth int depth int
depthSet bool depthSet bool
destination Path destination Path
placement placementMode placement PlacementMode
fieldSites map[string]importFieldSite fieldSites map[string]importFieldSite
} }
// importDefaults are the engine's private copy of load-wide discovery defaults.
type importDefaults struct {
required *bool
follow *bool
hidden *bool
recursive *bool
pattern []string
}
// ImportDefaults transports root-owned load defaults across the facade // ImportDefaults transports root-owned load defaults across the facade
// boundary. Load options snapshot this value before source acquisition. // boundary. Load options snapshot this value before source acquisition.
type ImportDefaults struct { type ImportDefaults struct {
@@ -66,26 +56,6 @@ type ImportDefaults struct {
// Pattern filters directory-search filenames; nil and empty differ. // Pattern filters directory-search filenames; nil and empty differ.
Pattern []string Pattern []string
} }
// OptionsWithImportDefaults copies root-owned defaults into ordinary options.
func OptionsWithImportDefaults[T any](options Options[T], value ImportDefaults) Options[T] {
options.importDefaults = privateImportDefaults(value)
return options
}
// LayeredOptionsWithImportDefaults copies root-owned defaults into grouped options.
func LayeredOptionsWithImportDefaults[T any](options LayeredOptions[T], value ImportDefaults) LayeredOptions[T] {
options.importDefaults = privateImportDefaults(value)
return options
}
func privateImportDefaults(value ImportDefaults) importDefaults {
return snapshotImportDefaults(importDefaults{
required: value.Required, follow: value.Follow, hidden: value.Hidden,
recursive: value.Recursive, pattern: value.Pattern,
})
}
type resolvedImportPolicy struct { type resolvedImportPolicy struct {
required bool required bool
follow bool follow bool
@@ -96,16 +66,16 @@ type resolvedImportPolicy struct {
depthSet bool depthSet bool
exclude []string exclude []string
destination Path destination Path
placement placementMode placement PlacementMode
} }
func snapshotImportDefaults(value importDefaults) importDefaults { func snapshotImportDefaults(value ImportDefaults) ImportDefaults {
return importDefaults{ return ImportDefaults{
required: cloneBool(value.required), Required: cloneBool(value.Required),
follow: cloneBool(value.follow), Follow: cloneBool(value.Follow),
hidden: cloneBool(value.hidden), Hidden: cloneBool(value.Hidden),
recursive: cloneBool(value.recursive), Recursive: cloneBool(value.Recursive),
pattern: cloneStrings(value.pattern), Pattern: cloneStrings(value.Pattern),
} }
} }
@@ -124,8 +94,8 @@ func cloneStrings(values []string) []string {
return append(make([]string, 0, len(values)), values...) return append(make([]string, 0, len(values)), values...)
} }
func validateImportDefaults(value importDefaults) []Diagnostic { func validateImportDefaults(value ImportDefaults) []Diagnostic {
for _, pattern := range value.pattern { for _, pattern := range value.Pattern {
if _, err := path.Match(pattern, ""); err != nil { if _, err := path.Match(pattern, ""); err != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")} return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")}
} }
@@ -133,10 +103,9 @@ func validateImportDefaults(value importDefaults) []Diagnostic {
return nil return nil
} }
func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (resolvedImportPolicy, []Diagnostic) { func resolveImportPolicy(spec importDeclarationSpec, defaults ImportDefaults) (resolvedImportPolicy, []Diagnostic) {
defaults = snapshotImportDefaults(defaults)
policy := resolvedImportPolicy{ policy := resolvedImportPolicy{
required: boolOr(spec.required, defaults.required, false), required: boolOr(spec.required, defaults.Required, false),
follow: true, follow: true,
hidden: true, hidden: true,
recursive: false, recursive: false,
@@ -147,15 +116,15 @@ func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (r
exclude: cloneStrings(spec.exclude), exclude: cloneStrings(spec.exclude),
} }
if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob { if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob {
policy.follow = boolOr(spec.follow, defaults.follow, true) policy.follow = boolOr(spec.follow, defaults.Follow, true)
policy.hidden = boolOr(spec.hidden, defaults.hidden, true) policy.hidden = boolOr(spec.hidden, defaults.Hidden, true)
} }
if spec.selector == importSelectorDirectory { if spec.selector == importSelectorDirectory {
policy.recursive = boolOr(spec.recursive, defaults.recursive, false) policy.recursive = boolOr(spec.recursive, defaults.Recursive, false)
if spec.patternSet { if spec.patternSet {
policy.pattern = cloneStrings(spec.pattern) policy.pattern = cloneStrings(spec.pattern)
} else { } else {
policy.pattern = cloneStrings(defaults.pattern) policy.pattern = cloneStrings(defaults.Pattern)
} }
} }
if spec.depthSet && !policy.recursive { if spec.depthSet && !policy.recursive {
@@ -180,26 +149,6 @@ func boolOr(explicit, application *bool, fallback bool) bool {
return fallback return fallback
} }
// parseImportSpecificationDirective validates every declaration before callers select candidates.
func parseImportSpecificationDirective(node *document.Node, directivePath Path) ([]importDeclarationSpec, []Diagnostic) {
if node.Kind != document.Mapping {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
}
var specs []importDeclarationSpec
var diagnostics []Diagnostic
for _, entry := range node.Mapping {
fieldPath := directivePath.Key(entry.Key)
if entry.Key != "imports" {
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
continue
}
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
specs = append(specs, parsed...)
diagnostics = append(diagnostics, issues...)
}
return specs, canonicalDiagnostics(diagnostics)
}
func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) { func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) {
if node == nil || node.Kind != document.Sequence { if node == nil || node.Kind != document.Sequence {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)} return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)}
@@ -224,7 +173,7 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
spec := importDeclarationSpec{ spec := importDeclarationSpec{
path: declarationPath.clone(), path: declarationPath.clone(),
location: publicLocation(node.Location), location: publicLocation(node.Location),
placement: placementModeMapping, placement: PlacementModeMapping,
fieldSites: make(map[string]importFieldSite, len(node.Mapping)), fieldSites: make(map[string]importFieldSite, len(node.Mapping)),
} }
selectors := 0 selectors := 0
@@ -251,40 +200,17 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
} }
switch entry.Key { switch entry.Key {
case "required", "recursive", "follow", "hidden": case "required", "recursive", "follow", "hidden":
if entry.Value.Kind != document.Boolean { if diagnostic := parseImportBooleanAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, entry.Key+" must be a boolean", entry.Value)} return importDeclarationSpec{}, []Diagnostic{*diagnostic}
}
value := entry.Value.Text == "true"
switch entry.Key {
case "required":
spec.required = &value
case "recursive":
spec.recursive = &value
case "follow":
spec.follow = &value
case "hidden":
spec.hidden = &value
} }
case "pattern": case "pattern":
values, origins, ok, badIndex := parseStringSet(entry.Value) if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
if !ok { return importDeclarationSpec{}, []Diagnostic{*diagnostic}
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "pattern", badIndex)}
} }
if badIndex, ok := invalidWildcard(values); ok {
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "pattern contains an invalid wildcard expression", badNode)}
}
spec.pattern, spec.patternSet = values, true
case "exclude": case "exclude":
values, origins, ok, badIndex := parseStringSet(entry.Value) if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
if !ok { return importDeclarationSpec{}, []Diagnostic{*diagnostic}
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "exclude", badIndex)}
} }
if badIndex, ok := invalidWildcard(values); ok {
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "exclude contains an invalid wildcard expression", badNode)}
}
spec.exclude, spec.excludeSet = values, true
case "depth": case "depth":
depth, ok := parseImportDepth(entry.Value) depth, ok := parseImportDepth(entry.Value)
if !ok { if !ok {
@@ -298,17 +224,11 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
} }
spec.destination = destination spec.destination = destination
case "placement": case "placement":
if entry.Value.Kind != document.String { placement, diagnostic := parseImportPlacement(entry.Value, fieldPath)
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)} if diagnostic != nil {
} return importDeclarationSpec{}, []Diagnostic{*diagnostic}
switch entry.Value.Text {
case "mapping":
spec.placement = placementModeMapping
case "sequence-item":
spec.placement = placementModeSequenceItem
default:
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
} }
spec.placement = placement
default: default:
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)} return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
} }
@@ -316,33 +236,98 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
selectorPath := declarationPath.Key(selectorEntry.Key) selectorPath := declarationPath.Key(selectorEntry.Key)
spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)} spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)}
if diagnostic := parseImportSelectorValue(&spec, selectorEntry.Key, selectorEntry.Value, selectorPath); diagnostic != nil {
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
}
return spec, nil
}
func parseImportBooleanAssignment(spec *importDeclarationSpec, field string, node *document.Node, fieldPath Path) *Diagnostic {
if node.Kind != document.Boolean {
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, field+" must be a boolean", node)
return &diagnostic
}
value := node.Text == "true"
switch field {
case "required":
spec.required = &value
case "recursive":
spec.recursive = &value
case "follow":
spec.follow = &value
case "hidden":
spec.hidden = &value
}
return nil
}
func parseImportWildcardAssignment(spec *importDeclarationSpec, field string, node *document.Node, fieldPath Path) *Diagnostic {
values, origins, ok, badIndex := parseStringSet(node)
if !ok {
diagnostic := stringSetDiagnostic(node, fieldPath, field, badIndex)
return &diagnostic
}
if badIndex, ok := invalidWildcard(values); ok {
badPath, badNode := listMemberSite(node, fieldPath, origins[badIndex])
diagnostic := nodeImportDiagnostic(CodeImportInvalid, badPath, field+" contains an invalid wildcard expression", badNode)
return &diagnostic
}
switch field {
case "pattern":
spec.pattern, spec.patternSet = values, true
case "exclude":
spec.exclude = values
}
return nil
}
func parseImportPlacement(node *document.Node, fieldPath Path) (PlacementMode, *Diagnostic) {
if node.Kind != document.String {
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
return PlacementModeMapping, &diagnostic
}
switch node.Text {
case "mapping":
return PlacementModeMapping, nil
case "sequence-item":
return PlacementModeSequenceItem, nil
default:
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
return PlacementModeMapping, &diagnostic
}
}
func parseImportSelectorValue(spec *importDeclarationSpec, field string, node *document.Node, selectorPath Path) *Diagnostic {
switch spec.selector { switch spec.selector {
case importSelectorPath, importSelectorDirectory: case importSelectorPath, importSelectorDirectory:
values, origins, ok, badIndex := parseStringSet(selectorEntry.Value) values, origins, ok, badIndex := parseStringSet(node)
if !ok { if !ok {
name := selectorEntry.Key diagnostic := stringSetDiagnostic(node, selectorPath, field, badIndex)
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(selectorEntry.Value, selectorPath, name, badIndex)} return &diagnostic
} }
for index, value := range values { for index, value := range values {
if !validImportPath(value) { if !validImportPath(value) {
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index]) badPath, badNode := listMemberSite(node, selectorPath, origins[index])
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)} diagnostic := nodeImportDiagnostic(CodeImportInvalid, badPath, field+" contains an invalid source path", badNode)
return &diagnostic
} }
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index]) badPath, badNode := listMemberSite(node, selectorPath, origins[index])
spec.selectorSites = append(spec.selectorSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)}) spec.selectorSites = append(spec.selectorSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)})
} }
spec.paths = values spec.paths = values
case importSelectorGlob: case importSelectorGlob:
if selectorEntry.Value.Kind != document.String || !validImportPath(selectorEntry.Value.Text) { if node.Kind != document.String || !validImportPath(node.Text) {
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", selectorEntry.Value)} diagnostic := nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", node)
return &diagnostic
} }
segments, ok := validGlobPattern(selectorEntry.Value.Text) segments, ok := validGlobPattern(node.Text)
if !ok { if !ok {
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", selectorEntry.Value)} diagnostic := nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", node)
return &diagnostic
} }
spec.glob, spec.globSegments = selectorEntry.Value.Text, segments spec.glob, spec.globSegments = node.Text, segments
} }
return spec, nil return nil
} }
func selectorForField(field string) importSelector { func selectorForField(field string) importSelector {

View File

@@ -31,14 +31,14 @@ func TestParseImportDeclarationSpecNormalizesPresenceAndPlacement(t *testing.T)
if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" { if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" {
t.Fatalf("path selector = %#v", first) t.Fatalf("path selector = %#v", first)
} }
if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != placementModeSequenceItem { if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != PlacementModeSequenceItem {
t.Fatalf("explicit declaration values = %#v", first) t.Fatalf("explicit declaration values = %#v", first)
} }
if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" { if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" {
t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String()) t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String())
} }
second := specs[1] second := specs[1]
if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || !second.excludeSet || second.exclude == nil { if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || second.exclude == nil {
t.Fatalf("empty list presence was lost: %#v", second) t.Fatalf("empty list presence was lost: %#v", second)
} }
third := specs[2] third := specs[2]
@@ -123,13 +123,13 @@ func TestParseImportDeclarationSpecValidatesDepthSyntaxAndPolicy(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics) t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics)
} }
_, diagnostics = resolveImportPolicy(specs[0], importDefaults{}) _, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" { if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" {
t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics) t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics)
} }
recursive := true recursive := true
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{recursive: &recursive}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{Recursive: &recursive})
if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet { if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet {
t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics) t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics)
} }
@@ -142,7 +142,7 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], importDefaults{}) omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], ImportDefaults{})
if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil { if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil {
t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics) t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics)
} }
@@ -159,12 +159,12 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
appRequired, appRecursive, appFollow, appHidden := true, true, true, true appRequired, appRecursive, appFollow, appHidden := true, true, true, true
defaults := importDefaults{ defaults := ImportDefaults{
required: &appRequired, Required: &appRequired,
recursive: &appRecursive, Recursive: &appRecursive,
follow: &appFollow, Follow: &appFollow,
hidden: &appHidden, Hidden: &appHidden,
pattern: []string{"*.yaml"}, Pattern: []string{"*.yaml"},
} }
policy, diagnostics := resolveImportPolicy(specs[0], defaults) policy, diagnostics := resolveImportPolicy(specs[0], defaults)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -188,16 +188,24 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
} }
snapshot := snapshotImportDefaults(defaults) snapshot := snapshotImportDefaults(defaults)
*defaults.required = false *defaults.Required = false
defaults.pattern[0] = "*.toml" *defaults.Follow = false
if snapshot.required == nil || !*snapshot.required || snapshot.pattern[0] != "*.yaml" { *defaults.Hidden = false
*defaults.Recursive = false
defaults.Pattern[0] = "*.toml"
if snapshot.Required == nil || !*snapshot.Required || snapshot.Follow == nil || !*snapshot.Follow ||
snapshot.Hidden == nil || !*snapshot.Hidden || snapshot.Recursive == nil || !*snapshot.Recursive || snapshot.Pattern[0] != "*.yaml" {
t.Fatalf("snapshot shares mutable inputs: %#v", snapshot) t.Fatalf("snapshot shares mutable inputs: %#v", snapshot)
} }
empty := snapshotImportDefaults(importDefaults{pattern: []string{}}) omittedSnapshot := snapshotImportDefaults(ImportDefaults{})
if empty.pattern == nil || len(empty.pattern) != 0 { if omittedSnapshot.Required != nil || omittedSnapshot.Follow != nil || omittedSnapshot.Hidden != nil || omittedSnapshot.Recursive != nil {
t.Fatal("snapshot changed omitted booleans into explicit values")
}
empty := snapshotImportDefaults(ImportDefaults{Pattern: []string{}})
if empty.Pattern == nil || len(empty.Pattern) != 0 {
t.Fatalf("snapshot lost explicit empty pattern: %#v", empty) t.Fatalf("snapshot lost explicit empty pattern: %#v", empty)
} }
if snapshotImportDefaults(importDefaults{}).pattern != nil { if snapshotImportDefaults(ImportDefaults{}).Pattern != nil {
t.Fatal("snapshot changed omitted pattern into explicit empty") t.Fatal("snapshot changed omitted pattern into explicit empty")
} }
} }
@@ -207,7 +215,7 @@ func TestInvalidImportDefaultPatternsFailBeforeReadingSource(t *testing.T) {
reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")} reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")}
_, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{ _, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{
importDefaults: importDefaults{pattern: []string{"["}}, ImportDefaults: ImportDefaults{Pattern: []string{"["}},
}) })
var loadErr *LoadError var loadErr *LoadError
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid { if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
@@ -224,11 +232,11 @@ func parseImportSpecsYAML(t *testing.T, declarations string) ([]importDeclaratio
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
} }
index := documentEntryIndex(parsed.document.Mapping, directiveKey) extracted, diagnostics := extractRootDirectives(parsed.document, rootDirectiveContextSource)
if index < 0 { if len(diagnostics) != 0 {
t.Fatal("missing root directive in test fixture") return nil, diagnostics
} }
return parseImportSpecificationDirective(parsed.document.Mapping[index].Value, Keys(directiveKey)) return extracted.imports, nil
} }
type countingImportSpecReader struct { type countingImportSpecReader struct {

File diff suppressed because it is too large Load Diff

View File

@@ -113,14 +113,22 @@ tasks: [{id: z-parent}]
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n") aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n") bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{ sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}})
Imports: true, input, diagnostics := testAcquireInput(inputAcquisitionRequest{
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}), input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
}) directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{
sequencePolicy: sequencePolicy,
importLimits: defaultImportLimits(),
}, nil)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble imports: %#v", diagnostics) t.Fatalf("assemble imports: %#v", diagnostics)
} }
tasks, ok := documentAtPath(input.document, Keys("tasks")) composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy)
if len(diagnostics) != 0 {
t.Fatalf("compose acquired imports: %#v", diagnostics)
}
tasks, ok := documentAtPath(composition.root, Keys("tasks"))
if !ok || tasks.Kind != document.Sequence { if !ok || tasks.Kind != document.Sequence {
t.Fatalf("tasks document = %#v, %v", tasks, ok) t.Fatalf("tasks document = %#v, %v", tasks, ok)
} }
@@ -153,7 +161,12 @@ func TestAcquiredContextsUseSelectedFileWhenSourceRecordsAreReordered(t *testing
{ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile}, {ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile},
{ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile}, {ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile},
} }
contexts, diagnostic := contextsForAcquiredSources(File(mainPath), nil, nil, sources) mainInfo := &SourceInfo{Path: canonicalTestPath(t, mainPath)}
resolved, diagnostic := resolveFilesystemContext(File(mainPath), nil, parsedSource{source: mainInfo}, nil)
if diagnostic != nil {
t.Fatalf("resolve main context: %#v", diagnostic)
}
contexts, diagnostic := contextsForResolvedSources(&resolved, sources)
if diagnostic != nil { if diagnostic != nil {
t.Fatalf("source contexts: %#v", diagnostic) t.Fatalf("source contexts: %#v", diagnostic)
} }
@@ -398,9 +411,9 @@ label: helper
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n") childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
loaded, err := LoadLayered( loaded, err := LoadLayered(
Input{Source: NoSource()}, testSecretBinding(), Input{Source: NoSource(), Imports: true}, testSecretBinding(),
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem, Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem,
}}}}}, }}}}},
) )
if err != nil { if err != nil {
@@ -490,7 +503,7 @@ func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testi
t.Fatalf("config = %#v", result.Config) t.Fatalf("config = %#v", result.Config)
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != placementModeSequenceItem || sources[2].placementMode != placementModeMapping || sources[2].Destination.String() != "/other" { if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != PlacementModeSequenceItem || sources[2].placementMode != PlacementModeMapping || sources[2].Destination.String() != "/other" {
t.Fatalf("source placement evidence = %#v", sources) t.Fatalf("source placement evidence = %#v", sources)
} }
} }
@@ -508,7 +521,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n") helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
result, err := testLoad[config](File(mainPath), Options[config]{ result, err := testLoad[config](File(mainPath), Options[config]{
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem, Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
}) })
if err != nil { if err != nil {
t.Fatalf("load main item: %v", err) t.Fatalf("load main item: %v", err)
@@ -517,7 +530,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
t.Fatalf("main item = %#v", result.Config.Items) t.Fatalf("main item = %#v", result.Config.Items)
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != placementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != placementModeMapping { if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != PlacementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != PlacementModeMapping {
t.Fatalf("main item sources = %#v", sources) t.Fatalf("main item sources = %#v", sources)
} }
if sources[1].Path != canonicalTestPath(t, helperPath) { if sources[1].Path != canonicalTestPath(t, helperPath) {
@@ -536,7 +549,7 @@ func TestProjectAcquiredDestinationsUsesMappedSequenceItemIndex(t *testing.T) {
items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}},
}}} }}}
scope := acquisitionScope{contributions: []acquiredContribution{{ scope := acquisitionScope{contributions: []acquiredContribution{{
source: SourceInfo{ID: "outer-source", placementMode: placementModeSequenceItem}, source: SourceInfo{ID: "outer-source", placementMode: PlacementModeSequenceItem},
destination: Keys("records"), nested: child, destination: Keys("records"), nested: child,
}}} }}}
sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}} sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}}
@@ -617,9 +630,10 @@ id: item
`) `)
helperPath := writeImportFile(t, directory, "helper.yml", "value: acquired\n") helperPath := writeImportFile(t, directory, "helper.yml", "value: acquired\n")
accounting := &importAccounting{} accounting := &importAccounting{}
input, diagnostics := assembleBaseInputTracked(File(mainPath), Options[struct{}]{ input, diagnostics := testAcquireInput(inputAcquisitionRequest{
Imports: true, Filesystem: &FilesystemContext{Root: directory}, deferAssembly: true, input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: directory}},
}, Path{}, accounting, nil, nil) role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, accounting)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("acquire input: %#v", diagnostics) t.Fatalf("acquire input: %#v", diagnostics)
} }
@@ -674,12 +688,20 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
`) `)
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n") writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
writeImportFile(t, directory, "helper.yml", "id: helper-id\n") writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
input, diagnostics := assembleBaseInput(File(mainPath), Options[config]{Imports: true}) sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}})
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble item import: %#v", diagnostics) t.Fatalf("assemble item import: %#v", diagnostics)
} }
imported, diagnostics := compositionForInputWithPolicy(input, sequencePolicy)
if len(diagnostics) != 0 {
t.Fatalf("compose acquired item import: %#v", diagnostics)
}
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n") authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
composed, diagnostics := composeDocumentsWithPolicy(mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}), authored, input.document) composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy, authored, imported.root)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("compose authored and imported lists: %#v", diagnostics) t.Fatalf("compose authored and imported lists: %#v", diagnostics)
} }
@@ -738,7 +760,7 @@ func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
- glob: records/*.yml - glob: records/*.yml
placement: sequence-item placement: sequence-item
`) `)
if len(declarations) != 2 || declarations[0].placement != placementModeMapping || declarations[1].placement != placementModeSequenceItem { if len(declarations) != 2 || declarations[0].placement != PlacementModeMapping || declarations[1].placement != PlacementModeSequenceItem {
t.Fatalf("declaration placements = %#v", declarations) t.Fatalf("declaration placements = %#v", declarations)
} }
} }
@@ -862,7 +884,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n") writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n")
_, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{ _, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Imports: true, importDefaults: importDefaults{required: &required}, Imports: true, ImportDefaults: ImportDefaults{Required: &required},
}) })
var loadError *LoadError var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
@@ -874,7 +896,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
_, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{ _, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{
main: loadInput{source: File(basePath)}, main: loadInput{source: File(basePath)},
underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}}, underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}},
importDefaults: importDefaults{required: &required}, importDefaults: ImportDefaults{Required: &required},
}, testSecretBinding(), func(string) (string, bool) { return "", false }) }, testSecretBinding(), func(string) (string, bool) { return "", false })
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("grouped underlay required default error = %#v", err) t.Fatalf("grouped underlay required default error = %#v", err)
@@ -951,7 +973,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n") itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n")
helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n") helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n")
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble input: %#v", diagnostics) t.Fatalf("assemble input: %#v", diagnostics)
} }
@@ -983,10 +1008,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
break break
} }
} }
if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.nested == nil { if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.pendingItem == nil {
t.Fatalf("outer item contribution = %#v", item) t.Fatalf("outer item contribution = %#v", item)
} }
itemScope := item.nested.acquisition[0] itemScope := item.pendingItem
if itemScope.owner != item.identity || len(itemScope.contributions) != 2 { if itemScope.owner != item.identity || len(itemScope.contributions) != 2 {
t.Fatalf("nested item scope = %#v", itemScope) t.Fatalf("nested item scope = %#v", itemScope)
} }
@@ -1082,7 +1107,7 @@ id: item
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
writeImportFile(t, directory, "helper.yml", "id: helper\n") writeImportFile(t, directory, "helper.yml", "id: helper\n")
options := Options[importTestConfig]{ options := Options[importTestConfig]{
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem, Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
} }
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict) loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
diagnostic := loadError.Diagnostics()[0] diagnostic := loadError.Diagnostics()[0]
@@ -1092,6 +1117,70 @@ id: item
}) })
} }
func TestBroadItemScopesKeepHelperOriginsAndDestinations(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
directory := t.TempDir()
const count = 32
var main strings.Builder
main.WriteString("configkit:\n imports:\n")
for index := count - 1; index >= 0; index-- {
name := strconv.Itoa(100 + index)
main.WriteString(" - path: item-" + name + ".yml\n destination: [items]\n placement: sequence-item\n")
writeImportFile(t, directory, "item-"+name+".yml", "configkit: {imports: [{path: helper-"+name+".yml}]}\nid: item-"+name+"\n")
writeImportFile(t, directory, "helper-"+name+".yml", "value: helper-"+name+"\n")
}
mainPath := writeImportFile(t, directory, "main.yml", main.String())
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
}}}
for _, layered := range []bool{false, true} {
t.Run(strconv.FormatBool(layered), func(t *testing.T) {
var loaded *Loaded[config]
var err error
if layered {
loaded, err = LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[config]{
Definition: definition,
Underlays: []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}},
})
} else {
loaded, err = Load(File(mainPath), testSecretBinding(), Options[config]{Imports: true, Definition: definition})
}
if err != nil {
t.Fatal(err)
}
result := testLoaded(loaded)
sources := result.Sources()
if len(result.Config.Items) != count || len(sources) != 2*count+1 {
t.Fatalf("item/source counts = %d/%d", len(result.Config.Items), len(sources))
}
metadata := result.Metadata()
for index, record := range result.Config.Items {
name := strconv.Itoa(100 + index)
if record.ID != "item-"+name || record.Value != "helper-"+name {
t.Fatalf("item %d = %#v", index, record)
}
// Acquisition remains authored (descending); final item order is
// dependency-ready (ascending), with helper evidence reprojected.
helper := sources[2*(count-index)]
field, found := metadata.Lookup(Keys("items").Index(index).Key("value"))
if !found || len(field.Origin.Locations) != 1 || field.Origin.Locations[0].SourceID != helper.ID ||
helper.Path != canonicalTestPath(t, filepath.Join(directory, "helper-"+name+".yml")) ||
helper.Destination.String() != Keys("items").Index(index).String() {
t.Fatalf("item %d helper evidence = %#v, %#v", index, field, helper)
}
}
})
}
}
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) { func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit: mainPath := writeImportFile(t, directory, "main.yml", `configkit:
@@ -1104,12 +1193,19 @@ func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n") writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n")
writeImportFile(t, directory, "a-dependency.yml", "a: true\n") writeImportFile(t, directory, "a-dependency.yml", "a: true\n")
writeImportFile(t, directory, "c-independent.yml", "c: true\n") writeImportFile(t, directory, "c-independent.yml", "c: true\n")
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble item scope: %#v", diagnostics) t.Fatalf("assemble item scope: %#v", diagnostics)
} }
composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy{})
if len(diagnostics) != 0 {
t.Fatalf("compose acquired item scope: %#v", diagnostics)
}
var nested *composedDocument var nested *composedDocument
for _, contribution := range input.composition.acquisition[0].contributions { for _, contribution := range composition.acquisition[0].contributions {
if contribution.role == importRoleItem { if contribution.role == importRoleItem {
nested = contribution.nested nested = contribution.nested
break break
@@ -1268,7 +1364,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}}, {name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, test.limits) request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
_, _, diagnostics := testAcquireImportWithLimits(request, test.limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("budget diagnostics = %#v", diagnostics) t.Fatalf("budget diagnostics = %#v", diagnostics)
} }
@@ -1278,7 +1375,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n") writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n") writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
main = parseImportMain(t, mainPath) main = parseImportMain(t, mainPath)
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes}) request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
_, _, diagnostics := testAcquireImportWithLimits(request, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("depth diagnostics = %#v", diagnostics) t.Fatalf("depth diagnostics = %#v", diagnostics)
} }
@@ -1312,15 +1410,17 @@ func TestImportDepthBudgetCountsSelectedEdges(t *testing.T) {
writeImportFile(t, directory, "parts/next.yml", "next: true\n") writeImportFile(t, directory, "parts/next.yml", "next: true\n")
limits := defaultImportLimits() limits := defaultImportLimits()
limits.depth = 1 limits.depth = 1
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, parseImportMain(t, mainPath), nil, limits) main := parseImportMain(t, mainPath)
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
if test.code != "" { if test.code != "" {
if len(diagnostics) != 1 || diagnostics[0].Code != test.code { if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code) t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
} }
return return
} }
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 { if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
t.Fatalf("depth-boundary composition: sources=%#v diagnostics=%#v", sources, diagnostics) t.Fatalf("depth-boundary acquisition: sources=%#v diagnostics=%#v", sources, diagnostics)
} }
}) })
} }
@@ -1342,9 +1442,10 @@ func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
bytes: main.bytes + int64(len(data)), bytes: main.bytes + int64(len(data)),
nodes: countInputNodes(main.document) + countInputNodes(imported.document), nodes: countInputNodes(main.document) + countInputNodes(imported.document),
} }
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits) request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 { acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics) if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
t.Fatalf("exact-limit acquisition = (%#v, %#v, %#v)", acquired, sources, diagnostics)
} }
} }
@@ -1401,6 +1502,50 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
return parsed return parsed
} }
func testImportRequest(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, processing *sourceVersionProcessingResult) importAcquisitionRequest {
return importAcquisitionRequest{
input: inputAcquisitionRequest{
input: loadInput{
source: source, imports: true, filesystem: context,
destination: destination.clone(), placementMode: main.source.placementMode,
},
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
protectionRule: rule,
},
main: main, processing: processing,
}
}
func testAcquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, accounting *importAccounting) (assembledInput, []Diagnostic) {
if accounting == nil {
accounting = &importAccounting{}
}
state := &acquisitionState{accounting: accounting}
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)}
return acquireInput(request, policy, state, tier)
}
func testAcquireImport(request importAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (*composedDocument, []SourceInfo, []Diagnostic) {
composition, sources, _, diagnostics := acquireImportedInput(request, &policy, state, tier)
return composition, sources, diagnostics
}
func testComposeAcquiredImport(request importAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, acquired *composedDocument, sources []SourceInfo) (*composedDocument, []Diagnostic) {
input := assembledInput{
parsed: request.main, composition: acquired, acquisition: cloneAcquisitionScopes(acquired.acquisition),
sources: sources, versionState: state.versionState, accounting: state.accounting,
}
return composeAcquiredInputWithPolicies(input, collectionPolicyScope{}, policy.sequencePolicy)
}
func testAcquireImportWithLimits(request importAcquisitionRequest, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: &importAccounting{}}, &tierAcquisitionState{})
}
func testAcquireImportWithAccounting(request importAcquisitionRequest, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, &tierAcquisitionState{})
}
func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec { func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec {
t.Helper() t.Helper()
declarations, diagnostics := parseImportSpecificationYAML(input) declarations, diagnostics := parseImportSpecificationYAML(input)

View File

@@ -114,26 +114,7 @@ func cloneInheritanceOperations(operations []inheritanceOperation) []inheritance
} }
func inheritancePolicyScopeAtPath(scope collectionPolicyScope, prefix Path) collectionPolicyScope { func inheritancePolicyScopeAtPath(scope collectionPolicyScope, prefix Path) collectionPolicyScope {
localized := collectionPolicyScope{} return newInheritancePolicyIndex(scope).localize(prefix)
for _, entry := range scope.collections {
if relative, inside := relativeHistoryPath(prefix, entry.destination); inside {
localized.set(relative, cloneCollectionPolicyForInheritance(entry.policy))
continue
}
relative, inside := relativeHistoryPath(entry.destination, prefix)
if !inside || len(relative.segments) == 0 || relative.segments[0].kind != pathSegmentIndex {
continue
}
record, found := entry.policy.recordPolicyAt(relative.segments[0].index)
if !found {
continue
}
remainder := Path{segments: append([]pathSegment(nil), relative.segments[1:]...)}
for _, nested := range inheritancePolicyScopeAtPath(record.scope, remainder).collections {
localized.set(nested.destination, cloneCollectionPolicyForInheritance(nested.policy))
}
}
return localized
} }
func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix Path) []projectedCollectionPolicy { func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix Path) []projectedCollectionPolicy {
@@ -148,15 +129,11 @@ func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix
return projected return projected
} }
// A replacement retains the displaced source/derivation record while the // A replacement retains the before and after values for history accounting.
// effective origin belongs to the child. Value trees are borrowed immutable // The trees are borrowed immutable inputs or detached templates.
// inputs or detached templates and are budgeted with retained history later.
type inheritanceReplacement struct { type inheritanceReplacement struct {
path Path before *document.Node
before *document.Node after *document.Node
after *document.Node
previous []displacedDefinition
effective Origin
} }
// A link represents one shared relationship edge. Each contributor retains // A link represents one shared relationship edge. Each contributor retains
@@ -192,11 +169,6 @@ func inheritanceDirective(node *document.Node) (document.Entry, bool) {
return document.Entry{}, false return document.Entry{}, false
} }
func inheritanceDirectiveLocation(node *document.Node) Location {
entry, _ := inheritanceDirective(node)
return publicLocation(entry.Value.Location)
}
func withoutInheritanceDirective(node *document.Node) *document.Node { func withoutInheritanceDirective(node *document.Node) *document.Node {
payload := *node payload := *node
payload.Mapping = make([]document.Entry, 0, len(node.Mapping)) payload.Mapping = make([]document.Entry, 0, len(node.Mapping))

View File

@@ -103,15 +103,19 @@ func mergeInheritanceCollectionPolicies(
for key, entry := range child.collections { for key, entry := range child.collections {
paths[key] = entry.destination.clone() paths[key] = entry.destination.clone()
} }
var lookup *documentLookup
if len(paths) != 0 {
lookup = buildDocumentLookup(parentValue, childValue)
}
var diagnostics []Diagnostic var diagnostics []Diagnostic
for _, path := range paths { for _, path := range paths {
relative, inside := relativeHistoryPath(parentRootPath, path) relative, inside := relativeHistoryPath(parentRootPath, path)
var retained, incoming *document.Node var retained, incoming *document.Node
if inside { if inside {
if !replaced.contains(path, false) { if !replaced.contains(path, false) {
retained, _ = documentAtPath(parentValue, relative) retained, _ = documentAtPathWithLookup(parentValue, relative, lookup)
} }
incoming, _ = documentAtPath(childValue, relative) incoming, _ = documentAtPathWithLookup(childValue, relative, lookup)
} }
resolution, lower, _, active, issues := inheritanceCollectionResolution(path, parent, child, applicationPolicy, incoming) resolution, lower, _, active, issues := inheritanceCollectionResolution(path, parent, child, applicationPolicy, incoming)
if len(issues) != 0 { if len(issues) != 0 {
@@ -224,19 +228,17 @@ func applyInheritanceSequencePolicy(
} }
func applyInheritanceSequenceOrigins( func applyInheritanceSequenceOrigins(
result *inheritanceScope,
origins, incomingOrigins map[string]Origin, origins, incomingOrigins map[string]Origin,
path Path, path Path,
lower, higher *document.Node, lower, higher *document.Node,
combined sequenceCombinationResult, combined sequenceCombinationResult,
operation sequenceMode, operation sequenceMode,
) { ) []inheritanceReplacement {
var replacements []inheritanceReplacement
if operation == sequenceModeReplace { if operation == sequenceModeReplace {
if lower != nil { if lower != nil {
displaced := displacedOrigins(lower, origins, path) replacements = append(replacements, inheritanceReplacement{
result.replacements = append(result.replacements, inheritanceReplacement{ before: lower, after: higher,
path: path.clone(), before: lower, after: higher,
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
}) })
deleteOriginSubtree(origins, path, lower) deleteOriginSubtree(origins, path, lower)
} }
@@ -249,7 +251,7 @@ func applyInheritanceSequenceOrigins(
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex)) copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
} }
} }
return return replacements
} }
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()]) mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
@@ -261,12 +263,10 @@ func applyInheritanceSequenceOrigins(
lowerPath := path.Index(replacement.lowerIndex) lowerPath := path.Index(replacement.lowerIndex)
higherPath := path.Index(replacement.higherIndex) higherPath := path.Index(replacement.higherIndex)
outputPath := path.Index(replacement.outputIndex) outputPath := path.Index(replacement.outputIndex)
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex]) deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath) copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath)
result.replacements = append(result.replacements, inheritanceReplacement{ replacements = append(replacements, inheritanceReplacement{
path: outputPath, before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex], before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
}) })
} }
for _, projection := range combined.higherItems { for _, projection := range combined.higherItems {
@@ -274,6 +274,7 @@ func applyInheritanceSequenceOrigins(
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex)) copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
} }
} }
return replacements
} }
func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic { func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic {

View File

@@ -0,0 +1,117 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
type inheritanceTemplateResult struct {
value *document.Node
origins map[string]Origin
collectionPolicies collectionPolicyScope
policyTemplates []collectionPolicyScope
controls []inheritanceControl
transfers []inheritanceTransfer
replacements []inheritanceReplacement
operations []inheritanceOperation
}
func combineInheritanceTemplate(
template inheritanceExecutionTemplate,
child preparedInheritanceChild,
parent *preparedInheritanceParent,
parentOrigins map[string]Origin,
applicationPolicy sequencePolicy,
) (inheritanceTemplateResult, []Diagnostic) {
result := inheritanceTemplateResult{}
if child.control != nil {
result.controls = append(result.controls, *child.control)
result.operations = append(result.operations, inheritanceOperation{
control: child.control, controlOrigin: child.controlOrigin,
})
}
if parent == nil {
result.value = document.Clone(child.payload)
result.origins = child.origins
result.collectionPolicies = cloneCollectionPolicyScopeForInheritance(child.policyTemplates)
result.policyTemplates = []collectionPolicyScope{child.policyTemplates}
return result, nil
}
mergedPolicyScope, diagnostics := mergeInheritanceCollectionPolicies(
parent.parentPolicyScope, parent.childPolicyScope,
template.path, parent.payload, child.payload,
applicationPolicy, parent.replaced,
)
if len(diagnostics) != 0 {
return inheritanceTemplateResult{}, diagnostics
}
mergedOrigins := transferredInheritanceOrigins(parentOrigins, parent.transfer.from, template.path, parent.transfer)
mergeInheritanceOrigin(mergedOrigins, template.path, child.origins[template.path.String()])
var sequenceContributions []sequenceHistoryContribution
var combinationDiagnostics []Diagnostic
mergeRecursiveMappings(parent.payload, child.payload, template.path, new(mappingIndex), recursiveMergeHooks{
added: func(childPath Path, incoming *document.Node) {
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
},
combined: func(childPath Path, _ *document.Node) {
mergeInheritanceOrigin(mergedOrigins, childPath, child.origins[childPath.String()])
},
replaced: func(childPath Path, existing, incoming *document.Node) {
result.replacements = append(result.replacements, inheritanceReplacement{
before: existing, after: incoming,
})
deleteOriginSubtree(mergedOrigins, childPath, existing)
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
},
merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) {
resolution, _, _, active, issues := inheritanceCollectionResolution(
childPath, parent.parentPolicyScope, parent.childPolicyScope, applicationPolicy, incoming,
)
if len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...)
return existing, false
}
if !active {
return nil, false
}
lowerValue := existing
combined, problems := combineSequence(lowerValue, incoming, resolution, sequenceCombinationContext{destination: childPath})
if len(problems) != 0 {
combinationDiagnostics = append(combinationDiagnostics, problems...)
return existing, false
}
policy := applyInheritanceSequencePolicy(
childPath, combined, resolution, parent.parentPolicyScope, parent.childPolicyScope,
)
mergedPolicyScope.set(childPath, policy)
result.replacements = append(result.replacements, applyInheritanceSequenceOrigins(
mergedOrigins, child.origins, childPath, lowerValue, incoming, combined, resolution.operation,
)...)
sequenceContributions = append(sequenceContributions, sequenceHistoryContributionForCombination(
childPath, resolution.operation, incoming, combined,
))
return combined.value, true
},
})
preserveInheritanceIdentityFields(parent.payload, child.payload, template.identityFields)
if len(combinationDiagnostics) != 0 {
return inheritanceTemplateResult{}, canonicalDiagnostics(combinationDiagnostics)
}
if problems := validateRegisteredInheritanceSequences(parent.payload, template.path, applicationPolicy); len(problems) != 0 {
return inheritanceTemplateResult{}, problems
}
parent.payload.Location = template.entry.Value.Location
result.value = parent.payload
result.origins = mergedOrigins
result.collectionPolicies = inheritancePolicyScopeAtPath(mergedPolicyScope, template.path)
result.policyTemplates = parent.effectivePolicyTemplates
result.transfers = append(result.transfers, parent.transfer)
result.operations = append(result.operations, inheritanceOperation{
transfer: &parent.transfer, parent: parent.historyParent, effective: parent.payload,
parentOrigins: parentOrigins, effectiveOrigins: mergedOrigins,
parentPolicies: cloneInheritancePolicyTemplates(parent.policyTemplates),
templatePolicies: cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
effectivePolicies: cloneInheritancePolicyTemplates(parent.effectivePolicyTemplates),
sequences: cloneSequenceHistoryContributions(sequenceContributions),
identityFields: cloneStrings(template.identityFields),
})
return result, nil
}

View File

@@ -0,0 +1,55 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func chargeInheritanceTemplateHistory(counter *retainedNodeCounter, result inheritanceTemplateResult, template inheritanceExecutionTemplate) *Diagnostic {
for _, operation := range result.operations {
if !counter.add(operation.parent) || !counter.add(operation.effective) {
diagnostic := inheritanceHistoryBudgetDiagnostic(template.path, template.entry.Value.Location)
return &diagnostic
}
}
for _, replacement := range result.replacements {
if !counter.add(replacement.before) || !counter.add(replacement.after) {
diagnostic := inheritanceHistoryBudgetDiagnostic(template.path, template.entry.Value.Location)
return &diagnostic
}
}
return nil
}
func finalizeInheritanceScope(
adapter inheritanceScopeAdapter,
result *inheritanceScope,
templates []inheritanceExecutionTemplate,
resolved []*document.Node,
resolvedOrigins []map[string]Origin,
resolvedPolicies []collectionPolicyScope,
inputOrigins map[string]Origin,
limits projectionLimits,
) []Diagnostic {
root := adapter.rebuild(resolved)
result.collectionPolicies = collectionPolicyScope{}
result.origins = directInheritanceOrigins(&document.Node{Kind: root.Kind, Location: root.Location}, adapter.scopePath(), inputOrigins)
policyIndex := newInheritancePolicyIndex(adapter.collectionPolicies())
for _, slot := range adapter.slots() {
if slot.hasTemplate {
index := slot.template
projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], templates[index].path)
for pointer, origin := range resolvedOrigins[index] {
result.origins[pointer] = origin
}
continue
}
localPolicies := policyIndex.localize(slot.path)
projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, slot.path)
for pointer, origin := range directInheritanceOrigins(slot.value, slot.path, inputOrigins) {
result.origins[pointer] = origin
}
}
result.root = root
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
return []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)}
}
return nil
}

View File

@@ -74,7 +74,6 @@ func applyInheritanceMetadata(metadata *Metadata, raw *document.Node, rawOrigins
}) })
applyOverlayMetadata(&sourceView, overlays) applyOverlayMetadata(&sourceView, overlays)
metadata.history = sourceView.history metadata.history = sourceView.history
metadata.inheritance = cloneInheritanceOperations(expansion.operations)
} }
func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change { func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change {

View File

@@ -124,6 +124,7 @@ func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocume
} }
metadata := metadataFromDecoded(decoded) metadata := metadataFromDecoded(decoded)
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded) applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
metadata.inheritance = cloneInheritanceOperations(expanded.operations)
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root) finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
return &Inspection{ return &Inspection{
metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root, metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root,
@@ -388,15 +389,18 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) { func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) {
shared := &document.Node{Kind: document.String, Text: "shared"} shared := &document.Node{Kind: document.String, Text: "shared"}
extra := &document.Node{Kind: document.String, Text: "extra"} extra := &document.Node{Kind: document.String, Text: "extra"}
expansion := &inheritanceScope{ inspection := &Inspection{
root: shared, inheritance: []inheritanceOperation{{parent: shared, effective: shared}},
operations: []inheritanceOperation{{parent: shared, effective: shared}}, metadata: Metadata{inheritance: []inheritanceOperation{{parent: shared, effective: extra}}},
replacements: []inheritanceReplacement{{before: shared, after: extra}},
} }
if !historyNodesWithin(2, nil, nil, nil, nil, nil, expansion) || counter := newRetainedNodeCounter(2)
historyNodesWithin(1, nil, nil, nil, nil, nil, expansion) { if !addInspectionHistory(counter, inspection) || counter.count != 2 {
t.Fatal("shared evidence node accounting is incorrect") t.Fatal("shared evidence node accounting is incorrect")
} }
counter = newRetainedNodeCounter(1)
if addInspectionHistory(counter, inspection) {
t.Fatal("distinct inheritance evidence did not consume the budget")
}
} }
func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) { func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) {

View File

@@ -24,6 +24,83 @@ func parseIdentifiedInheritanceSequence(t *testing.T, sourceID, input string) *d
return sequence return sequence
} }
func TestPreserveInheritanceIdentityIndexesCompoundFields(t *testing.T) {
t.Parallel()
mergedEntries := []document.Entry{
{Key: "before", Value: &document.Node{Kind: document.String, Text: "keep"}},
{Key: "id", KeyLocation: document.Location{SourceID: "parent", Line: 2}, ValueLocation: document.Location{SourceID: "parent", Line: 2}, Value: &document.Node{Kind: document.String, Text: "parent-id"}},
}
for index := 0; index < 64; index++ {
key := "setting-" + string(rune('a'+index))
mergedEntries = append(mergedEntries, document.Entry{Key: key, Value: &document.Node{Kind: document.String, Text: key}})
}
mergedEntries = append(mergedEntries,
document.Entry{Key: "a/b~c", KeyLocation: document.Location{SourceID: "parent", Line: 70}, ValueLocation: document.Location{SourceID: "parent", Line: 70}, Value: &document.Node{Kind: document.String, Text: "parent-literal"}},
document.Entry{Key: "missing", Value: &document.Node{Kind: document.String, Text: "retained"}},
document.Entry{Key: "after", Value: &document.Node{Kind: document.String, Text: "keep"}},
)
merged := &document.Node{Kind: document.Mapping, Mapping: mergedEntries}
child := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "a/b~c", KeyLocation: document.Location{SourceID: "child", Line: 9, Column: 3}, ValueLocation: document.Location{SourceID: "child", Line: 9, Column: 10}, Value: &document.Node{Kind: document.String, Text: "child-literal", Location: document.Location{SourceID: "child", Line: 9, Column: 10}}},
{Key: "id", KeyLocation: document.Location{SourceID: "child", Line: 3, Column: 3}, ValueLocation: document.Location{SourceID: "child", Line: 3, Column: 7}, Value: &document.Node{Kind: document.String, Text: "child-id", Location: document.Location{SourceID: "child", Line: 3, Column: 7}}},
}}
beforeKeys := make([]string, len(merged.Mapping))
for index, entry := range merged.Mapping {
beforeKeys[index] = entry.Key
}
untouched := merged.Mapping[2].Value
preserveInheritanceIdentityFields(merged, child, []string{"id", "a/b~c", "missing"})
for index, entry := range merged.Mapping {
if entry.Key != beforeKeys[index] {
t.Fatalf("mapping order changed at %d: %q, want %q", index, entry.Key, beforeKeys[index])
}
}
for _, want := range []struct {
key string
text string
keyLine int
keyColumn int
valueLine int
valueColumn int
}{
{key: "id", text: "child-id", keyLine: 3, keyColumn: 3, valueLine: 3, valueColumn: 7},
{key: "a/b~c", text: "child-literal", keyLine: 9, keyColumn: 3, valueLine: 9, valueColumn: 10},
} {
var got document.Entry
for _, entry := range merged.Mapping {
if entry.Key == want.key {
got = entry
break
}
}
if got.Value == nil || got.Value.Text != want.text || got.KeyLocation.SourceID != "child" || got.KeyLocation.Line != want.keyLine || got.KeyLocation.Column != want.keyColumn ||
got.ValueLocation.SourceID != "child" || got.ValueLocation.Line != want.valueLine || got.ValueLocation.Column != want.valueColumn ||
got.Value.Location.SourceID != "child" || got.Value.Location.Line != want.valueLine || got.Value.Location.Column != want.valueColumn {
t.Fatalf("restored identity %q = %#v", want.key, got)
}
if got.Value == child.Mapping[0].Value || (want.key == "id" && got.Value == child.Mapping[1].Value) {
t.Fatalf("restored identity %q borrows its child value", want.key)
}
}
if merged.Mapping[2].Value != untouched {
t.Fatal("nonidentity sibling was replaced")
}
for _, entry := range merged.Mapping {
if entry.Key == "missing" && entry.Value.Text != "retained" {
t.Fatalf("identity absent from child was changed: %#v", entry)
}
}
child.Mapping[0].Value.Text = "mutated"
for _, entry := range merged.Mapping {
if entry.Key == "a/b~c" && entry.Value.Text != "child-literal" {
t.Fatal("restored identity value aliases the child template")
}
}
}
func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) { func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) {
sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2 sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2
tenant: acme tenant: acme
@@ -198,11 +275,6 @@ func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *test
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found { if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
t.Fatal("nested inherited value did not retain its destination origin") t.Fatal("nested inherited value did not retain its destination origin")
} }
for _, replacement := range expanded.replacements {
if replacement.path.String() == "/jobs/0/id" {
t.Fatal("parent identity was treated as payload replaced by the child's identity")
}
}
} }
func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) { func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) {

View File

@@ -44,11 +44,8 @@ func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *tes
if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 { if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 {
t.Fatalf("direct replacement origin = %#v", count) t.Fatalf("direct replacement origin = %#v", count)
} }
if len(result.replacements) != 1 || result.replacements[0].path.String() != "/child/count" || if len(result.replacements) != 1 || result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" {
result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" || t.Fatalf("replacement accounting values = %#v", result.replacements)
len(result.replacements[0].previous) != 1 ||
len(projectInheritanceSteps(result.replacements[0].previous[0].origin)) != 1 {
t.Fatalf("displaced inheritance = %#v", result.replacements)
} }
settings := result.origins["/child/settings"] settings := result.origins["/child/settings"]
if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" || if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" ||

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

View File

@@ -0,0 +1,111 @@
package engine
import (
"fmt"
"strings"
"testing"
)
func TestInheritancePolicyIndexKeepsSemanticScopesAndOwnedTemplates(t *testing.T) {
policy := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
nested := collectionPolicyScope{}
nested.set(Keys("group", "base", "items"), policy)
outer := policy
record := newCollectionRecordPolicy()
record.scope = nested
outer.setRecordPolicyAt(0, record)
scope := collectionPolicyScope{}
scope.set(Keys("records"), outer)
scope.set(Keys("numeric", "0", "items"), policy)
index := newInheritancePolicyIndex(scope)
first := index.localize(Keys("records").Index(0).Key("group"))
second := index.localize(Keys("records").Index(0).Key("group"))
if len(first.collections) != 1 || len(second.collections) != 1 {
t.Fatal("record-owned scope was not localized")
}
entry := first.collections["/base/items"]
entry.policy.identityFields[0] = "changed"
if second.collections["/base/items"].policy.identityFields[0] != "id" || nested.collections["/group/base/items"].policy.identityFields[0] != "id" {
t.Fatal("localized policy shares mutable template state")
}
if len(index.localize(Keys("records", "0")).collections) != 0 || len(index.localize(Keys("numeric").Index(0)).collections) != 0 {
t.Fatal("mapping keys and sequence indexes were conflated")
}
if len(index.localize(Keys("numeric", "0")).collections) != 1 {
t.Fatal("numeric mapping key did not retain its policy")
}
left := index.localize(Keys("records"))
right := index.localize(Keys("records"))
leftOwner := left.collections[""].policy.recordOrder[0]
rightOwner := right.collections[""].policy.recordOrder[0]
if leftOwner == rightOwner || leftOwner == record.owner || rightOwner == record.owner {
t.Fatal("independent templates share a record owner")
}
}
func TestInheritancePolicyIndexReplacesOnlyTheChangedScope(t *testing.T) {
scope := collectionPolicyScope{}
policy := collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true}
for index := range 128 {
scope.set(Keys(fmt.Sprintf("group-%03d", index), "pending"), policy)
}
index := newInheritancePolicyIndex(scope)
changed := Keys("group-064")
if len(index.absoluteScope(changed).collections) != 1 {
t.Fatal("scope includes unrelated groups")
}
replacement := collectionPolicyScope{}
replacement.set(changed.Key("introduced"), policy)
index.replace(&scope, changed, replacement)
if len(scope.collections) != 128 {
t.Fatal("replacement lost unrelated pending declarations")
}
local := index.localize(changed)
if _, found := local.collections["/introduced"]; !found || len(local.collections) != 1 {
t.Fatal("retired policy survived or introduced policy was not indexed")
}
index.replace(&scope, changed, collectionPolicyScope{})
if len(index.localize(changed).collections) != 0 || len(scope.collections) != 127 {
t.Fatal("descendant retirement did not update the table and index together")
}
if len(index.localize(Keys("group-065")).collections) != 1 {
t.Fatal("retirement changed another scope")
}
}
func TestInheritanceWideIndependentYamlCollectionScopes(t *testing.T) {
var body strings.Builder
body.WriteString("configkit:\n sequences:\n")
for index := range 64 {
fmt.Fprintf(&body, " - {destination: [groups, group-%03d, base, items], key: id, mode: append}\n", index)
}
body.WriteString("groups:\n")
for index := range 64 {
fmt.Fprintf(&body, " group-%03d:\n base: {items: [{id: shared, value: parent}]}\n child: {extends: base, items: [{id: shared, value: child}]}\n", index)
}
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type definition struct {
Items []item `yaml:"items"`
}
type config struct {
Groups map[string]map[string]definition `yaml:"groups"`
}
loaded, err := testLoad[config](Bytes("wide-inheritance", []byte(body.String())))
if err != nil {
t.Fatal(err)
}
metadata := loaded.Metadata()
for name, group := range loaded.Config.Groups {
if len(group["base"].Items) != 1 || group["base"].Items[0].Value != "parent" || len(group["child"].Items) != 1 || group["child"].Items[0].Value != "child" {
t.Fatalf("independent scope %s composed incorrectly: %#v", name, group)
}
path := Keys("groups", name, "child", "items").Index(0).Key("value")
if info, exists := metadata.Lookup(path); !exists || !info.Explicit || info.Origin.Layer != LayerBase {
t.Fatalf("inherited destination lost source metadata at %s", path)
}
}
}

Some files were not shown because too many files have changed in this diff Show More