177 Commits
v0.9.0 ... main

Author SHA1 Message Date
d818b08556 Record successful remote vanity verification 2026-11-01 14:24:36 +00:00
587f407a32 Migrate ConfigKit to its vanity module path 2026-11-01 14:19:37 +00:00
335bd7bc31 Settle the Forge publication endpoint 2026-11-01 14:04:15 +00:00
331195202e Document the vanity module migration roadmap 2026-11-01 14:02:28 +00:00
21c9bc0ad0 Retire completed roadmaps before the next release 2026-10-27 19:15:40 +00:00
68400ffdfb Fix source lookup tests with symlinked temporary directories 2026-10-27 18:42:19 +00:00
5d5d51ab11 Record the public API cleanup review 2026-10-27 16:25:33 +00:00
69bd9ead53 Complete the public API cleanup roadmap 2026-10-27 15:44:00 +00:00
2ff588eb64 Update guidance for the cleaned public API 2026-10-27 15:40:22 +00:00
c96197c417 Add detached source lookup to results and errors 2026-10-27 15:26:19 +00:00
24ab623cb2 Rename the retained history limit diagnostic 2026-10-27 15:18:57 +00:00
1ce147c9f6 Use main for the main provenance layer 2026-10-27 15:15:39 +00:00
9833ca3aa3 Replace generic resolution recording with intent methods 2026-10-27 15:12:38 +00:00
eafbd32489 Clarify search and secret bootstrap names 2026-10-27 15:09:04 +00:00
9ac0053590 Clarify import default field names 2026-10-27 15:07:15 +00:00
791cf16136 Simplify empty import selection evidence 2026-10-27 15:05:22 +00:00
31ae30372e Clarify source version evidence names 2026-10-27 15:01:54 +00:00
7ee9e92ef5 Clarify resolution metadata and sensitivity names 2026-10-27 15:00:04 +00:00
556b10b2cd Plan the public API cleanup 2026-10-27 14:55:22 +00:00
07e4ae5d92 Expand accepted API decisions into an implementation roadmap 2026-10-27 14:43:08 +00:00
7548fa101a Accept descriptive import default field names 2026-10-27 14:35:42 +00:00
197567177e Include failed-load source lookup in A1 2026-10-27 14:31:25 +00:00
7330107389 Include obsolete recording diagnostic removal in D6 2026-10-27 14:29:35 +00:00
ad6d3748d9 Accept history terminology for the retained evidence limit 2026-10-27 14:27:56 +00:00
1ee508ba02 Accept main terminology for provenance 2026-10-27 14:26:54 +00:00
e9155fced6 Accept the discovery path constructor rename 2026-10-27 14:25:18 +00:00
14c83a6515 Record additional public API review opportunities 2026-10-27 14:21:01 +00:00
3ebb848ead Accept direct source lookup on results 2026-10-27 14:10:51 +00:00
4289f9ff7f Record accepted public API cleanup decisions 2026-10-27 14:06:45 +00:00
a050986916 Catalog the public API for version one 2026-10-27 13:34:26 +00:00
fb71421956 Prepare sensitivity dependencies once per recorded operation 2026-10-27 03:18:55 +00:00
b281adb751 Index explanation selections once per query 2026-10-27 03:15:01 +00:00
1448f9b19d Deduplicate parsed sensitivity paths correctly 2026-10-27 03:12:21 +00:00
731149fc6d Localize history selection to affected paths 2026-10-27 03:10:07 +00:00
9ff873f54c Index stable history trees during normalization 2026-10-27 03:08:34 +00:00
3f389662e9 Keep explanation origins scoped to the query 2026-10-27 03:05:15 +00:00
906ca34b6d Avoid duplicate intermediate source history 2026-10-27 03:04:31 +00:00
17216a40f3 Preserve final dynamic secret tags in inspection 2026-10-27 03:02:17 +00:00
896c1cde08 Record additional history scaling findings 2026-10-27 03:00:14 +00:00
91769e9f9b Record interface remediation review findings 2026-10-27 02:52:47 +00:00
98c8b7c842 Complete interface contract remediation 2026-10-26 18:23:58 +00:00
1954052631 Preserve composition conflict claimant order 2026-10-26 18:17:04 +00:00
3ffaef7ede Remove superseded history attribution paths 2026-10-26 18:11:04 +00:00
6a1447c327 Reconcile retained history accounting 2026-10-26 17:46:31 +00:00
12fd0a7191 Publish inspection through one frozen history model 2026-10-26 17:36:36 +00:00
8155465322 Move sensitivity classification to shared history 2026-10-26 17:25:11 +00:00
408dd13c24 Integrate history prefixes into load handoffs 2026-10-26 17:15:30 +00:00
738c8a9cc7 Replay explanations through shared history selections 2026-10-26 17:01:32 +00:00
4b4e15fc58 Cut metadata history over to shared model 2026-10-26 16:34:13 +00:00
76521f7cd8 Complete historical inventory and query services 2026-10-26 16:09:32 +00:00
cacc558288 Normalize original resolution history evidence 2026-10-26 15:49:15 +00:00
231ae66857 Normalize typed CLI history evidence 2026-10-26 15:36:42 +00:00
da74d4190d Normalize inheritance history evidence 2026-10-26 15:25:02 +00:00
11f7154874 Normalize projected collection history evidence 2026-10-26 15:09:12 +00:00
3828b117e8 Normalize baseline and source history evidence 2026-10-26 15:01:54 +00:00
2419e63717 Add shape-aware history selection core 2026-10-26 14:47:18 +00:00
a893c7716c Preserve explicitness across resolution shape changes 2026-10-26 14:36:15 +00:00
28ef9b6200 Use entry metadata for resolution presence 2026-10-26 14:30:25 +00:00
0dd16aa8a9 Plan the interface remediation sprint 2026-10-26 14:24:07 +00:00
f6d54a094a Clarify shared history migration and sprint acceptance 2026-10-26 14:00:18 +00:00
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
269 changed files with 14565 additions and 5670 deletions

View File

@@ -11,7 +11,9 @@ source groups with an independently selected main input. Sources can be host
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings. files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
Enabled explicit imports compose strictly within their group; precedence Enabled explicit imports compose strictly within their group; precedence
between groups combines mappings recursively. Both entry points return a complete, between groups combines mappings recursively. Both entry points return a complete,
validated configuration with detached metadata and safe diagnostics. validated configuration with detached metadata and safe diagnostics. Successful
results and load errors also provide passive `Source(id)` lookups alongside source
enumeration.
Applications may perform audited defaulting and normalization through a resolver, Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret tags and a path predicate drive deterministic redacted effective YAML. Typed secret
@@ -45,7 +47,14 @@ 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
import "go.maximumdirect.net/configkit"
type Config struct { type Config struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"` Workers int `yaml:"workers"`
@@ -60,6 +69,9 @@ func ReadConfig(path string) (Config, error) {
} }
``` ```
Existing consumers should follow the
[module-path migration guidance](docs/consumers/README.md#vanity-module-path-migration).
## Design philosophy ## Design philosophy
ConfigKit favors one coherent, predictable configuration model over flexibility ConfigKit favors one coherent, predictable configuration model over flexibility

View File

@@ -1,6 +1,6 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "go.maximumdirect.net/configkit/internal/engine"
func fromEngineLocation(value engine.Location) Location { func fromEngineLocation(value engine.Location) Location {
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column} return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
@@ -46,45 +46,34 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ return SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), DestinationIsFallback: engine.SourceInfoDestinationUnresolvedForFacade(value),
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionEvidence: fromEngineSourceVersionEvidence(value.VersionCheck),
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)), EmptyImportSelections: fromEngineEmptyImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
} }
} }
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { func fromEngineEmptyImportSelections(values []engine.ImportSelectionForFacade) []EmptyImportSelection {
result := engine.SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination),
Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck),
}
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
}
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
if values == nil { if values == nil {
return nil return nil
} }
result := make([]ImportSelection, len(values)) result := make([]EmptyImportSelection, len(values))
for index, value := range values { for index, value := range values {
result[index] = ImportSelection{ result[index] = EmptyImportSelection{
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location), Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome), Selector: ImportSelector(value.Selector),
} }
} }
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,51 +81,24 @@ 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.FollowSymlinks,
Hidden: cloneImportDefaultBool(value.Hidden), Hidden: value.IncludeHidden,
Recursive: cloneImportDefaultBool(value.Recursive), Recursive: value.Recursive,
Pattern: cloneImportDefaultStrings(value.Pattern), Pattern: value.Pattern,
} }
} }
func cloneImportDefaultBool(value *bool) *bool { func fromEngineSourceVersionEvidence(value *engine.SourceVersionCheck) *SourceVersionEvidence {
if value == nil { if value == nil {
return nil return nil
} }
cloned := *value return &SourceVersionEvidence{
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 {
if value == nil {
return nil
}
return &SourceVersionCheck{
Path: fromEnginePath(value.Path), Path: fromEnginePath(value.Path),
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text}, Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
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)}
} }

View File

@@ -3,7 +3,7 @@ package configkit
import ( import (
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine" "go.maximumdirect.net/configkit/internal/engine"
) )
// LoadError reports safe structured diagnostics for a failed configuration load. // LoadError reports safe structured diagnostics for a failed configuration load.
@@ -50,6 +50,21 @@ func (e *LoadError) Sources() []SourceInfo {
return result return result
} }
// Source returns a detached record from the sanitized source evidence retained
// before loading failed, including placement evidence available at that point.
// IDs are local to the failed load. It does not acquire sources or access the
// filesystem. Empty or unknown IDs return false.
func (e *LoadError) Source(id string) (SourceInfo, bool) {
if e == nil || e.value == nil {
return SourceInfo{}, false
}
value, ok := e.value.Source(id)
if !ok {
return SourceInfo{}, false
}
return fromEngineSourceInfo(value), true
}
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal // WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes. // control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {

View File

@@ -1,6 +1,6 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "go.maximumdirect.net/configkit/internal/engine"
// Difference describes one canonical changed region between two successful // Difference describes one canonical changed region between two successful
// loads. Left is the earlier value and Right is the later value. Each snapshot // loads. Left is the earlier value and Right is the later value. Each snapshot

90
discovery.go Normal file
View File

@@ -0,0 +1,90 @@
package configkit
import "go.maximumdirect.net/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...)}
}
// SearchPaths describes literal host-file candidates in the supplied order.
func SearchPaths(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()`, `SearchPaths()`, 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
@@ -93,6 +172,18 @@ the returned configuration or inspection records cannot alter ConfigKit's privat
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
frozen redacted projection. frozen redacted projection.
`Result.Source(id)` returns the detached record for an acquired source already
listed by `Result.Sources()`. Source IDs are local to one load; empty and unknown
IDs return false. This passive lookup uses retained evidence and does not reacquire
the source, access the filesystem, or look up credentials. A source ID can be
obtained from the source records or exact origin locations.
`LoadError.Source(id)` performs the same lookup over the partial, already-sanitized
inventory returned by `LoadError.Sources()`. It preserves the source evidence and
placement available when loading failed, which may be incomplete; it does not
promise a record for every attempted source. Nil or zero-valued errors and empty
or unknown IDs return false.
### Completion and resource lifetime ### Completion and resource lifetime
`Load` is synchronous and complete on return. Success means input assembly, `Load` is synchronous and complete on return. Success means input assembly,
@@ -149,7 +240,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
@@ -198,7 +290,7 @@ the application root and are not moved beneath the main input's destination.
`SourceInfo.Destination` reports each acquired source's effective mapping or `SourceInfo.Destination` reports each acquired source's effective mapping or
sequence-container path, including imports. `PlacementMode` reports how that sequence-container path, including imports. `PlacementMode` reports how that
source was placed; mapping is the zero value, and CLI/overlay sources report source was placed; mapping is the zero value, and CLI/overlay sources report
mapping unless item placement was applied. `DestinationUnresolved` is true only mapping unless item placement was applied. `DestinationIsFallback` is true only
when an enclosing item index needed to name the source's destination has not yet when an enclosing item index needed to name the source's destination has not yet
been assigned. In that case `Destination` reports the nearest resolved enclosing been assigned. In that case `Destination` reports the nearest resolved enclosing
collection container and omits the unresolved local suffix. A source's own item collection container and omits the unresolved local suffix. A source's own item
@@ -365,10 +457,10 @@ successful result. Callback panics remain programming failures.
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how `Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
one `Definition.Version` policy applies across source documents. The zero value one `Definition.Version` policy applies across source documents. The zero value
`SourceVersionModeDefault` preserves the existing main-only check at its `SourceVersionModeMainOnly` (the zero value) preserves the existing main-only
effective path and existing protected-path behavior. `SourceVersionModeIfPresent` check at its effective path and existing protected-path behavior.
checks each participating document that declares a version and permits absent `SourceVersionModeIfPresent` checks each participating document that declares a
non-main declarations. `SourceVersionModeRequired` rejects a participating version and permits absent non-main declarations. `SourceVersionModeRequired` rejects a participating
document without a declaration. Enabled modes require a usable `VersionRule` document without a declaration. Enabled modes require a usable `VersionRule`
and checker; an unknown mode or unusable rule is an options error before source and checker; an unknown mode or unusable rule is an options error before source
access or callbacks. access or callbacks.
@@ -405,7 +497,7 @@ declaration locations use source-local coordinates; ordinary payload
diagnostics continue to use effective paths. Default-mode diagnostics are diagnostics continue to use effective paths. Default-mode diagnostics are
unchanged. unchanged.
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not `SourceInfo.VersionEvidence` is nil when enabled declaration processing did not
occur. Otherwise it records a source-local `Path`, the observed `Version` state occur. Otherwise it records a source-local `Path`, the observed `Version` state
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest (`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
available `Location`, and whether text was redacted. A non-nil record with available `Location`, and whether text was redacted. A non-nil record with
@@ -481,7 +573,7 @@ reports it absent. Queries return detached records.
### Secret classification and effective rendering ### Secret classification and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional Secret classification combines `configkit:"secret"` field tags with the optional
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside pure `Definition.Sensitive(Path) bool` predicate. Tags on concrete structs inside
supported dynamic values are retained in typed projections, including superseded supported dynamic values are retained in typed projections, including superseded
defaults and pre-resolution values. A tagged or predicate-classified defaults and pre-resolution values. A tagged or predicate-classified
container makes all descendants sensitive. ConfigKit evaluates final and historical container makes all descendants sensitive. ConfigKit evaluates final and historical
@@ -544,7 +636,7 @@ writes remain visible. When structural replay differs from the pre-CLI typed inp
a `ChangeDecoding` event records retained defaults and canonical decoding. CLI a `ChangeDecoding` event records retained defaults and canonical decoding. CLI
contributions then retain their raw input representation while effective and contributions then retain their raw input representation while effective and
resolution values use the typed projection. This built-in inspection operation is resolution values use the typed projection. This built-in inspection operation is
not accepted by `Resolution.Record`. not exposed as a manual recording method.
Base, overlay, and CLI history preserves the policy-validated input representation, Base, overlay, and CLI history preserves the policy-validated input representation,
including a superseded value that could not have decoded into the final Go field. including a superseded value that could not have decoded into the final Go field.
@@ -579,11 +671,11 @@ unredacted mode.
Retained history consists of immutable source applications and audited subtree Retained history consists of immutable source applications and audited subtree
deltas, rather than a full tree copy per field. Shared nodes count once toward a deltas, rather than a full tree copy per field. Shared nodes count once toward a
4,000,000-node retained-history limit; exceeding it fails loading with 4,000,000-node retained-history limit; exceeding it fails loading with
`explain.limit_exceeded`. Replaying one requested path is performed on demand, and `history.limit_exceeded`. Replaying one requested path is performed on demand, and
the returned YAML strings and nested records cannot mutate later explanations. the returned YAML strings and nested records cannot mutate later explanations.
Application commands should render the separate `SecretsReport` immediately after Application commands should render the separate `SecretsReport` immediately after
an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`, an explicit `LoadSecretsIntoEnvironment` bootstrap, then use `WriteDiagnostics`,
`WriteEffective`, or `WriteExplanation` on the later load result as appropriate. `WriteEffective`, or `WriteExplanation` on the later load result as appropriate.
ConfigKit does not infer bootstrap outcomes from a secret reference and does not ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework. bundle these independent reports into a command framework.
@@ -678,11 +770,12 @@ reference state without printing its value.
## Explicit secrets-directory bootstrap ## Explicit secrets-directory bootstrap
`LoadSecretsDir(directory)` is a separate, opt-in startup operation. It is never `LoadSecretsIntoEnvironment(directory)` is a separate, opt-in startup operation.
called by `Load`, is not a YAML option, and does not participate in configuration It is never called by `Load`, is not a YAML option, and does not participate in
validation. Applications choose the native directory path and should invoke the configuration validation. Applications choose the native directory path and
operation once before starting concurrent work. The selected path may itself be a should invoke the operation once before starting concurrent work. The selected
symlink; ConfigKit resolves it to an existing canonical directory before examining path may itself be a symlink; ConfigKit resolves it to an existing canonical
directory before examining
its immediate entries. its immediate entries.
The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries. The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
@@ -722,7 +815,7 @@ per-load `Resolution` context. The context's
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and `IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults. null are never confused with absence or static defaults.
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed `Resolution.EntryInfo(path)` returns detached effective metadata from the fixed
post-decode entry state, including explicit CLI overrides, before application post-decode entry state, including explicit CLI overrides, before application
resolution runs. It includes retained static defaults, preserves explicit null, and resolution runs. It includes retained static defaults, preserves explicit null, and
uses the same exact semantic path matching as `Metadata.Lookup`; missing, uses the same exact semantic path matching as `Metadata.Lookup`; missing,
@@ -745,18 +838,20 @@ exit; changing them cannot affect later queries, results, or another load.
`Default` assigns and records a derived value only when its path is not explicit. `Default` assigns and records a derived value only when its path is not explicit.
`Normalize` always assigns and records. For transformations performed directly by `Normalize` always assigns and records. For transformations performed directly by
application code, `Resolution.Record` declares a derived-default or normalization application code, `Resolution.RecordDerivedDefault` and
operation; a recorded container covers changes to its descendants. Records require `Resolution.RecordNormalization` declare the operation already performed; a
a schema-valid path, valid kind, safe nonempty reason, and schema-valid dependency recorded container covers changes to its descendants. These methods do not assign
paths. Repeated records at one path are coalesced: reasons and dependencies retain values, and a derived-default record does not suppress work for explicit input.
their first occurrence, the last helper value is authoritative, and any normalization Records require a schema-valid path, safe nonempty reason, and schema-valid
makes the combined operation a normalization. dependency paths. Repeated records at one path are coalesced: reasons and
dependencies retain their first occurrence, the last helper value is authoritative,
and any normalization makes the combined operation a normalization.
ConfigKit projects the configuration before and after the callback. Every net change ConfigKit projects the configuration before and after the callback. Every net change
must have an exact or ancestor record. Helper-assigned values must agree with the must have an exact or ancestor record. Helper-assigned values must agree with the
final value at their declared path, which supports ordinary map-entry copy/writeback final value at their declared path, which supports ordinary map-entry copy/writeback
while detecting forgotten writeback or unrelated targets. Invalid paths, kinds, while detecting forgotten writeback or unrelated targets. Invalid paths, values,
values, records, helper targets, and unrecorded mutations fail the load. Equal-valued records, helper targets, and unrecorded mutations fail the load. Equal-valued
derived assignments may still record application intent even when there is no derived assignments may still record application intent even when there is no
structural change. structural change.
@@ -969,6 +1064,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
@@ -1128,6 +1229,8 @@ replaces rather than extends the default pattern. Nil `Pattern` leaves directory
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
a field applicable to selectors that do not support it, and load calls snapshot a field applicable to selectors that do not support it, and load calls snapshot
the supplied pointers and slice before source acquisition. the supplied pointers and slice before source acquisition.
The Go fields are named `FollowSymlinks` and `IncludeHidden`; the YAML keys
remain `follow` and `hidden`.
A source's `configkit.sequences` declarations apply independently of import A source's `configkit.sequences` declarations apply independently of import
enablement; a `configkit.imports` declaration still requires `Options.Imports`. enablement; a `configkit.imports` declaration still requires `Options.Imports`.
@@ -1163,15 +1266,15 @@ or glob selection fail with `import.missing`; it also makes missing named paths
fail. Requiredness does not suppress invalid paths, containment failures, fail. Requiredness does not suppress invalid paths, containment failures,
directories selected as files, permissions, parse errors, or composition errors. directories selected as files, permissions, parse errors, or composition errors.
For inspection, `SourceInfo.ImportSelections` records one outcome for each For inspection, `SourceInfo.EmptyImportSelections` records each completed optional
completed optional declaration whose entire selected set is empty. The record is declaration whose entire selected set is empty. The record is attached to its
attached to its declaring source, keeps the declaration path local to that source, declaring source, keeps the declaration path local to that source, and identifies
identifies the declaration mapping (including its `SourceID`) and selector, and the declaration mapping (including its `SourceID`) and selector. Records remain
uses outcome `none`. Records remain in declaration order and are available in declaration order and are available
through the existing result, load through the existing result, load-error, and callback source snapshots. They do
error, and callback source snapshots. They do not enumerate candidates, explain not enumerate candidates, explain
why a path was absent, or describe partial omissions. Empty `path: []` succeeds why a path was absent, or describe partial omissions. Empty `path: []` succeeds
regardless of requiredness: it produces a `none` record when effectively optional, regardless of requiredness: it produces a record when effectively optional,
and no record when effectively required. Snapshots detach the record paths and and no record when effectively required. Snapshots detach the record paths and
slices. slices.

View File

@@ -6,6 +6,33 @@ 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.
Import the public root package as `"go.maximumdirect.net/configkit"`; its package
name remains `configkit`.
## 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,20 +82,40 @@ 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
load-wide policy to enabled main, underlay, and transitive imports. Each field load-wide policy to enabled main, underlay, and transitive imports. Each field
is resolved independently: a declaration's explicit value wins, then the is resolved independently: a declaration's explicit value wins, then the
application default, then the library default. `Required`, `Follow`, `Hidden`, application default, then the library default. `Required`, `FollowSymlinks`,
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to `IncludeHidden`, and `Recursive` use nil pointers for unspecified values.
`Pattern` applies to
directory searches: nil leaves filenames unrestricted, while a non-nil empty directory searches: nil leaves filenames unrestricted, while a non-nil empty
slice selects no files. A declaration's own `pattern` replaces the application slice selects no files. A declaration's own `pattern` replaces the application
default. The defaults do not enable imports or change selected-overlay behavior. default. The defaults do not enable imports or change selected-overlay behavior.
The Go fields `FollowSymlinks` and `IncludeHidden` correspond to the unchanged
YAML keys `follow` and `hidden`.
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each `Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
declaring source's `ImportSelections`. These records identify the source-local declaring source's `EmptyImportSelections`. `Result.Source(id)` and
declaration path, its authored location and selector, and the `none` outcome; `LoadError.Source(id)` retrieve one retained record directly when its load-local
ID is known. These records identify the source-local declaration path, its
authored location and selector;
they do not include candidate names or a guessed reason for absence. Returned they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports) records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics. owns selector applicability and exact absence semantics.
@@ -78,7 +125,7 @@ owns selector applicability and exact absence semantics.
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates [`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in `SourceInfo.VersionEvidence` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The [`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines [configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption, participation, source-local lookup and diagnostics, non-main header consumption,
@@ -225,7 +272,7 @@ conversion API.
## Source-aware application path policy ## Source-aware application path policy
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained [`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
path-bearing example. Its application resolver uses `Resolution.InputInfo` and path-bearing example. Its application resolver uses `Resolution.EntryInfo` and
`Resolution.Source` before normalizing: relative CLI input uses an explicitly `Resolution.Source` before normalizing: relative CLI input uses an explicitly
supplied invocation directory, retained defaults use the canonical main source, supplied invocation directory, retained defaults use the canonical main source,
and a file-authored scalar uses its one declaring source. Absolute paths remain and a file-authored scalar uses its one declaring source. Absolute paths remain
@@ -262,10 +309,67 @@ The examples preserve domain meaning while adopting one configuration convention
parsers translate only explicitly set configuration flags into `Override` records. parsers translate only explicitly set configuration flags into `Override` records.
Separate invocation state remains outside configuration. Separate invocation state remains outside configuration.
- Credential fields migrate to environment-name `Secret` references. Applications - Credential fields migrate to environment-name `Secret` references. Applications
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap; explicitly call `LoadSecretsIntoEnvironment` before `Load` when they choose
file bootstrap;
blocked empty variables remain blocked, and ordinary `Load` never changes the blocked empty variables remain blocked, and ordinary `Load` never changes the
process environment. process environment.
### Vanity module path migration
ConfigKit's module identifier changed from
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit` before
v1.0.0. Update the dependency requirement in your application's `go.mod` and its
Go imports together, selecting a revision or release that declares the new module
path. The package name remains `configkit`; the configuration model and public
symbol names are unchanged by this identity migration.
Old and new import paths identify distinct Go packages, including distinct named
types. Coordinate adoption with any dependencies that exchange ConfigKit values
with your application; changing a requirement alone does not make those types
interchangeable. The maintained examples use the new path, but other repositories
must manage their own migrations.
Historical tags keep their original module declarations and contents. Older
releases such as v0.10.2 still use the old path and cannot be installed under the
vanity identifier simply because discovery can find their repository. A matching
migrated revision is needed; no old-path compatibility module is supplied here.
### Pre-v1 public API cleanup
The pre-v1 API cleanup replaces these names outright; it does not retain
compatibility aliases:
| Previous API | Current API |
| --- | --- |
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
| `Definition.Secret` | `Definition.Sensitive` |
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
| `SourceVersionCheck` | `SourceVersionEvidence` |
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
| `ImportSelection` | `EmptyImportSelection` |
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
| `Paths(paths...)` | `SearchPaths(paths...)` |
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
| `LayerBase` | `LayerMain` |
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
| `CodeResolveInvalidKind` / `CodeOptionsUnavailable` | Removed; no replacement |
| `ImportSelection.Outcome`, `ImportSelectionOutcome`, `ImportSelectionNone` | Removed; empty-selection records no longer carry an outcome |
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
| Enumerating `Result.Sources()` / `LoadError.Sources()` to find one ID | `Result.Source(id)` / `LoadError.Source(id)` |
| Provenance string `"base"` | `"main"` |
| History diagnostic string `"explain.limit_exceeded"` | `"history.limit_exceeded"` |
The YAML import keys `follow` and `hidden`, the `configkit:"secret"` tag, and
typed `Secret` references are unchanged. Consumers that compare literal strings
or keep serialized diagnostic/provenance expectations may need to update those
alongside Go references. This repository guidance does not claim that downstream
applications have migrated and does not select a release version for this
breaking pre-v1 change. `ChangeKind` and its inspection constants remain; use
`Default` and `Normalize` when the helper should both assign and record a value.
### Selected-overlay migration ### Selected-overlay migration
To adopt the current selected-overlay API, rename `Options.Profile`, To adopt the current selected-overlay API, rename `Options.Profile`,
@@ -291,7 +395,7 @@ focused behavioral owner:
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) | | `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture | | Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | | `LoadSecretsIntoEnvironment`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests | | Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` | | Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` | | Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |

View File

@@ -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
@@ -182,7 +183,48 @@ Current behavior belongs to the configuration contract's
The completed remediation record, including the algorithm-audit findings, is The completed remediation record, including the algorithm-audit findings, is
preserved at `ea46001:docs/roadmap/remediation.md`. preserved at `ea46001:docs/roadmap/remediation.md`.
The completed configuration-discovery roadmap, implementation plan, and
remediation record are preserved in Git history at `0369111`, for example
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
[configuration discovery](config.md#configuration-discovery); the public
[discovery declarations](../discovery.go) own the Go API.
The 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, internal-boundary simplification, shared-history,
and public API cleanup roadmaps, reviews, and resolved findings are preserved at
`68400ff` under `docs/roadmap/`. For example:
```sh
git show 68400ff:docs/roadmap/api.md
git show 68400ff:docs/roadmap/remediation.md
```
Their earlier completed implementation plans are preserved at
`bb5540b:docs/roadmap/implementation.md` (audit remediation),
`f6d54a0:docs/roadmap/implementation.md` (internal-boundary simplification), and
`07e4ae5:docs/roadmap/implementation.md` (shared history). The plan at
`68400ff:docs/roadmap/implementation.md` records the public API cleanup.
Current behavior and migration information belong to the
[configuration contract](config.md), public declaration comments, and
[consumer guide](consumers/README.md#pre-v1-public-api-cleanup).
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
@@ -230,16 +272,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,94 @@ 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.
History and inspection use one private evidence owner. The
[`history_model.go`](../../internal/engine/history_model.go) builder receives
baseline, source-composition, inheritance, typed-checkpoint, and CLI evidence
in [`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go),
before application resolution begins. The callback retains its separate fixed
entry view; it does not read a builder that continues to change. After successful
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
adds the original resolution records and their net replay groups. At the existing
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
freezes that builder and counts the retained document roots before secret lookup
and validation.
The frozen model is shared by `Metadata` and `Inspection`. Its shape-aware
selection in [`history_select.go`](../../internal/engine/history_select.go)
drives metadata history, explanation, and historical-path membership; the model
also supplies historical diagnostic locations and sensitivity/dependency
evidence. `Inspection` replays selected payloads to produce detached, safely
rendered values, while metadata queries return detached attribution records.
History and explanation behavior is covered by focused `history_*_test.go`
tests alongside metadata, secret, inheritance, and facade integration tests.
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

@@ -7,10 +7,10 @@ container image, generated package, or separate registry publication.
## Release identity and support ## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit` - Module: `go.maximumdirect.net/configkit`
- Primary branch: `main` - Primary branch: `main`
- Authoritative remote: `origin` - Authoritative remote: `origin`
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git` - Authoritative push URL: `ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git`
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases - Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
- Minimum Go version: the first stable patch of the selected Go series, or a - Minimum Go version: the first stable patch of the selected Go series, or a
higher patch when required by a dependency or specific correctness fix, as higher patch when required by a dependency or specific correctness fix, as
@@ -24,6 +24,9 @@ Release validation runs one offline race-enabled Go suite, one full vet pass,
and CGO-disabled package compilation for both Linux targets. macOS and CI are and CGO-disabled package compilation for both Linux targets. macOS and CI are
not release gates; do not wait for or poll CI. not release gates; do not wait for or poll CI.
The vanity module path identifies Go imports. Source/tag browser links and public
Go retrieval use the Forge HTTPS repository independently of SSH publication.
## 1. Select Go and prepare the tool ## 1. Select Go and prepare the tool
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
@@ -82,7 +85,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
@@ -91,7 +94,7 @@ executable first on `PATH` through the publication command.
Verify access to the authoritative remote without displaying credentials: Verify access to the authoritative remote without displaying credentials:
```sh ```sh
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main git ls-remote ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD refs/heads/main
``` ```
Publication requires explicit authorization. Local implementation commits do Publication requires explicit authorization. Local implementation commits do
@@ -111,7 +114,7 @@ Create `docs/releases/<version>.md`. Its first line must be exactly
`# ConfigKit <version>`, and it must end with the license and attribution footer: `# ConfigKit <version>`, and it must end with the license and attribution footer:
```markdown ```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md) [COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/docs/oss/attribution.md)
``` ```
Replace `VERSION` with the full tag and confirm the three target files exist in Replace `VERSION` with the full tag and confirm the three target files exist in
@@ -127,7 +130,7 @@ Push only that committed candidate to the literal authoritative URL. This does
not follow local tags: not follow local tags:
```sh ```sh
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main git push --no-follow-tags ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD:refs/heads/main
``` ```
ReleaseKit requires authoritative `main` to equal this candidate before it ReleaseKit requires authoritative `main` to equal this candidate before it
@@ -228,8 +231,8 @@ consumer_dir=$(mktemp -d)
( (
cd "$consumer_dir" cd "$consumer_dir"
go mod init example.invalid/configkit-release-check go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version" GOWORK=off GOPROXY=direct go get "go.maximumdirect.net/configkit@$release_version"
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit GOWORK=off go list -m -f '{{.Path}} {{.Version}}' go.maximumdirect.net/configkit
) )
``` ```

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
# 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 ## 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 +20,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 +56,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:
@@ -83,3 +83,22 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
across loads without introducing aliasing? across loads without introducing aliasing?
- What cache lifetime and concurrency design would make schema reuse worthwhile - What cache lifetime and concurrency design would make schema reuse worthwhile
without excessive retained memory? without excessive retained memory?
## 6. Public path comparison
Status: deferred pending a concrete consumer need. This preserves A2 from the
completed API review at `68400ff:docs/roadmap/review.md`.
`Path.String()` is a JSON Pointer spelling, not a complete semantic identity:
an explicit numeric mapping key and a sequence index can share it. The public
type is opaque and currently non-comparable.
If consumers need comparison, consider a small exact-representation operation
rather than exposing mutable segments or introducing a pattern language. A
proposal must distinguish exact segment representation, including parsed versus
explicit segments and invalid state, from resolution against a document. Pointer
string equality must not be presented as semantic equality.
Keeping `Path` solely as an opaque query/building value remains an alternative.
No current contract changes or implementation commitment follow from this entry;
a comparison operation can be added compatibly later.

268
docs/roadmap/migration.md Normal file
View File

@@ -0,0 +1,268 @@
# Vanity Module Path Migration
Status: repository implementation and exact-revision remote consumer verification
complete.
Planning baseline: `21c9bc0ad0315c4ac96268b487a52376558a18d7`.
This roadmap defines the target and work needed to adopt
`go.maximumdirect.net/configkit` before ConfigKit's initial v1.0.0 release.
It is not a staged implementation plan or authorization to push or publish.
The [configuration contract](../config.md), public declaration comments, and
[release policy](../policy/release.md) continue to own behavior outside the
module-identity migration.
## Purpose And Scope
Give ConfigKit a stable, project-owned Go module identifier independent of its
repository host, while retaining its existing package name, public facade,
configuration behavior, and implementation boundaries.
The migration changes the module path and therefore Go package/type identities.
It also brings ConfigKit's release endpoint references into agreement with its
current Forge hosting. These are related identity updates, not changes to the
loading lifecycle, YAML syntax, composition, provenance, history, or secrets.
No runtime API redesign or compatibility-shim system is needed for this scope.
## Current Baseline
| Area | Baseline | Target |
| --- | --- | --- |
| Module declaration | `gitea.maximumdirect.net/eric/ConfigKit` in [go.mod](../../go.mod) | `go.maximumdirect.net/configkit` |
| Self-imports | Old module prefix throughout production packages, tests, benchmarks, and examples | New module prefix with the same package layout |
| Public package | Root-owned `configkit` facade over private internal packages | Same name and ownership under the new module identifier |
| Vanity discovery | Root and nested requests already advertise the Forge repository | Existing discovery continues to locate the migrated module |
| Git remote | `origin` uses `ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git` | Retain this SSH endpoint and align release configuration with it |
| Release configuration | [releasekit.json](../../releasekit.json), its test, and [runbook](../release.md) still name the old Gitea HTTPS endpoint | Consistent Forge publication references and vanity-path consumer commands |
Path-sensitive test expectations include
[import_boundary_test.go](../../import_boundary_test.go),
[facade_candidate_test.go](../../facade_candidate_test.go), and
[releasekit_profile_test.go](../../releasekit_profile_test.go). Compilation alone
will not identify every stale literal: a boundary test using the old prefix can
silently stop checking the intended imports.
### Preliminary External Verification
Checks against the baseline, using Go 1.26.10 and disposable modules/caches
outside the repository, established:
- HTTPS discovery at the module root and a nested package path returns a
`go-import` mapping from `go.maximumdirect.net/configkit` to
`https://forge.maximumdirect.net/base-layer/configkit`.
- Anonymous HTTPS Git access returns the expected `main` revision and release
tags, including v0.10.2.
- Go resolves and downloads v0.10.2 through direct retrieval and the standard
public-proxy/fallback configuration, with checksum verification enabled.
- A disposable consumer's `go get go.maximumdirect.net/configkit@v0.10.2`
reaches the repository but rejects its old module declaration. That is the
expected remaining mismatch, not a discovery failure.
These observations demonstrate that discovery and retrieval infrastructure work;
they do not establish that the migrated module builds or can be installed yet,
nor promise continuing external-service availability. No vanity-service change
was identified as necessary by these checks.
## Targeted End State
### Canonical Go Identity
The module declares `go.maximumdirect.net/configkit`. Production imports,
external-package tests, maintained examples, and path-sensitive expectations use
that prefix consistently. The package name remains `configkit`, and the root
remains the sole supported consumer import surface. Existing internal packages
stay internal; the migration does not expose them or change dependency direction.
The v1 module uses the unsuffixed path, with ordinary root-level release tags such
as `v1.0.0`. There is no `/v1` import suffix or module subdirectory to introduce.
The [Go module reference](https://go.dev/ref/mod#major-version-suffixes) owns the
language/toolchain conventions behind that choice.
Existing dependency and tool module identities are separate from ConfigKit's
identity. In particular, this work does not automatically rename or upgrade the
ReleaseKit requirement/tool declaration because the vanity service also advertises
a ReleaseKit path. Any dependency-path change should be assessed separately
against that upstream module's declared identity and available versions. The
current Go minimum and dependency versions are not selected for change here.
### Consumer Migration
Current installation/import examples identify the vanity path. The consumer guide
explains updating the module requirement and Go imports together, including
transitive libraries that exchange ConfigKit types with an application. Packages
loaded under the old and new prefixes have different Go identities; changing a
requirement alone does not make those types interchangeable.
This is a coordinated pre-v1 identity change. Old tags keep their original module
declarations and contents. The migration does not rewrite them to make historical
versions installable under the new path. Requesting an older tag through the
vanity identifier can still produce the observed module-declaration mismatch
even after this migration; consumers need a revision declaring the new path.
Maintained examples in this repository migrate with the library. Updating other
applications or maintaining a separate old-path compatibility module is outside
this effort; neither is required to declare the repository changes complete.
### Repository And Publication Identity
The vanity path identifies Go packages. Forge URLs identify the source repository,
browser pages, and publication transport. They remain distinct: Go HTTPS discovery
can use the public Forge HTTPS repository while maintainers push through SSH.
The authoritative publication endpoint remains
`ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git`. Retaining the
existing SSH `origin` avoids an unnecessary transport/credential change.
ReleaseKit's exact `expected_push_url`, its existing profile test, and the
runbook's literal publication/recovery commands agree with that endpoint.
Browser/tag links in current release guidance refer to the Forge HTTPS project,
not to the SSH transport or vanity import path. Public Go retrieval continues
using the verified Forge HTTPS repository independently. Authenticated write
capability still belongs to ordinary authorized release preparation; anonymous
HTTPS read success does not prove push access.
The existing release sequence, validation profile, permissions, immutable tags,
and successful-publication completion point are unchanged. This migration does
not introduce CI waiting, proxy-propagation waiting, native macOS checks, or
consumer installation as permanent release gates.
## Work Needed From The Baseline
### Module Declaration, Imports, And Tests
Update the module directive and all ConfigKit self-imports as one coordinated
change across root wrappers, engine/document/YAML packages, tests, benchmarks,
and maintained examples. Preserve the existing package layout and public symbol
names. Do not mechanically replace every old-host URL: dependency identities,
upstream attribution, and historical references have different owners.
Review import-boundary predicates, reflection package-path assertions, and any
other literal module checks alongside imports. Retain their existing protection
with the new identity rather than removing assertions to make the migration pass.
Review module metadata/tidiness as appropriate after changing the module path,
without using that review to bundle unrelated upgrades or tool migrations.
### Release Configuration And Current Guidance
Align `releasekit.json`, its test, and the runbook's publication/recovery commands
with the retained SSH endpoint. Update the runbook's module identity, HTTPS source
links, and consumer verification examples accordingly. Reflect only necessary
endpoint wording changes in the adopted release policy if its current text
requires them; its release guarantees and gates remain the same.
Review README, declaration documentation, the consumer guide, and applicable
development/internal documentation for current identity references. Describe the
consumer import/type-identity migration in the existing consumer migration
guidance. New release documentation, when prepared through the normal procedure,
should explain the change and use version-matched Forge source/license links.
Preserve historical release records, accepted ADR decision content, revision-pinned
audit evidence, upstream provenance links, and third-party module paths where
they describe their original material. A historical old-host reference is not an
unmigrated self-import. This roadmap does not authorize rewriting that history or
changing licensing arrangements.
### Proportionate Verification
Use the existing [development checks](../development.md#baseline-validation)
and [testing policy](../policy/testing.md). Verify the migrated module builds,
the default suite and maintained examples pass, and public/dependency boundary
checks still enforce the root/internal separation under the new prefix. The
eventual release retains its existing validation, rather than acquiring a second
validation framework or an expanded platform matrix.
When the migrated commit is available remotely, a one-time disposable consumer
check should retrieve that exact revision through the vanity path, without a
local replacement or workspace override, and compile a small public-API use.
An exact revision before tagging can exercise the migration without creating a
test release. Report direct retrieval and default proxy/fallback results
separately where both are checked; do not infer a successful install from
`go list` or archive download alone.
This remote check is migration evidence when the revised commit is available,
not authorization to push and not a new publication-completion gate. Keep network
checks outside the deterministic default suite, avoid polling external services,
and preserve the policy that successful tag publication completes a release.
After publication, retrieval of the exact released version remains optional and
non-gating under the existing procedure. Record any unavailable verification
honestly instead of claiming that baseline retrieval validates migrated code.
## Completion Criteria
- The repository consistently declares and imports the vanity module path,
while preserving the package name, facade ownership, and configuration behavior.
- Current documentation, examples, path-sensitive tests, and Forge publication
references agree with the implemented identities.
- Applicable local checks pass, without unrelated dependency or contract changes.
- Migration evidence distinguishes the preliminary infrastructure checks from
any later successful consumer installation of the actual migrated revision.
- Historical releases remain unchanged, and downstream migration guidance
explains both import changes and distinct Go type identities.
Repository implementation completion, external retrieval evidence, downstream
application adoption, and release publication are separate outcomes. No push,
tag, or downstream change is authorized merely by completing this roadmap.
No significant open decision remains for this migration.
## Implementation And Verification
The module declaration and all ConfigKit self-imports now use
`go.maximumdirect.net/configkit`, including internal packages, external-package
tests, maintained examples, and benchmarks. The import-boundary predicates and
root-type reflection expectations use the new prefix. Review of every changed
Go hunk confirmed only the intended module-prefix replacements and publication
URL expectation; no loading behavior or public symbol names changed.
`releasekit.json`, its profile test, and the runbook now use the retained Forge
SSH publication endpoint, matching the existing effective `origin` push URL.
The runbook distinguishes that transport from Forge HTTPS browser links and
vanity-path consumer retrieval. README and the consumer guide identify the new
import path; migration guidance covers named-type identity and historical tags.
The Go minimum, dependency/tool versions, and `go.sum` are unchanged. Historical
release records, accepted ADRs, adopted policies, and upstream attribution were
left intact. Remaining old ConfigKit path references describe the migration's
baseline, consumer upgrade instructions, or historical releases.
Local validation passed on Linux with Go 1.26.10 and cached dependencies:
```sh
GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh
GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go mod tidy -diff
GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...
```
The everyday check included maintained examples, documentation links, root type
ownership, dependency boundaries, and the release profile. Module/package
inspection reported the new main-module identity and no old ConfigKit imports
in the assembled test dependency graph. The module-selected ReleaseKit built
outside the checkout and reported v0.4.2. Module tidiness produced no diff.
### Remote Consumer Verification
With subsequent user authorization, migration commit
`587f407a32dec1e00d2aa17d2790c293d994721d` was pushed to authoritative `main`
through the retained Forge SSH endpoint. No release tag was created or published.
Two disposable consumer modules outside the repository retrieved that exact
revision through `go.maximumdirect.net/configkit`, using separate fresh module
caches and Go 1.26.10:
- Direct retrieval with `GOPROXY=direct` passed.
- Retrieval with `GOPROXY=https://proxy.golang.org,direct` passed. This verifies
the standard proxy/fallback configuration, not a proxy-only availability claim.
Checksum verification was enabled, `GOWORK=off` disabled workspace overrides, and
neither consumer had a `replace` directive. Go selected
`v0.10.3-0.20261101141937-587f407a32de`; the downloaded origin metadata identified
the exact migration commit in the Forge HTTPS repository. Both consumers received
the same module and module-file checksums.
After retrieval, each consumer passed `GOPROXY=off go test -mod=readonly -v ./...`.
The smoke test compiled and exercised the public API: explicit false/zero input
over typed defaults, effective provenance with `LayerMain`, source lookup by an
origin's source ID, and effective YAML rendering. Module inspection confirmed
that ConfigKit came from the corresponding fresh download cache, not the checkout.
This completes the one-time remote migration verification. The Go-generated
pseudo-version identifies an untagged commit; it is not an additional release.
No CI waiting or release gate was introduced.

View File

@@ -6,7 +6,7 @@ import (
"os" "os"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func ExampleLoad() { func ExampleLoad() {
@@ -186,7 +186,8 @@ func ExampleOverride() {
}, },
Overrides: overrides, Overrides: overrides,
}) })
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err) source, _ := result.Source("source-1")
fmt.Println(result.Config.Workers, source.Label, err)
// Output: 0 --workers <nil> // Output: 0 --workers <nil>
} }

View File

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// Artifact is a Narratio-shaped generated output declaration. // Artifact is a Narratio-shaped generated output declaration.
@@ -167,7 +167,7 @@ func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Overri
resolveNotariusReference(configuration, resolution, invocationDir) resolveNotariusReference(configuration, resolution, invocationDir)
return nil return nil
}, },
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/sensitive" },
}, },
}) })
} }
@@ -177,7 +177,7 @@ func resolveNotariusReference(configuration *NotariusPathConfig, resolution *con
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) { if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
return return
} }
info, found := resolution.InputInfo(path) info, found := resolution.EntryInfo(path)
if !found { if !found {
resolution.Warning(path, "relative reference has no entry provenance") resolution.Warning(path, "relative reference has no entry provenance")
return return

View File

@@ -11,8 +11,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_narratio() { func Example_narratio() {
@@ -82,7 +82,7 @@ func Example_weatherStartup() {
defer os.RemoveAll(directory) defer os.RemoveAll(directory)
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600) _ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600) _ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
report, bootstrapErr := configkit.LoadSecretsDir(directory) report, bootstrapErr := configkit.LoadSecretsIntoEnvironment(directory)
var reportOutput bytes.Buffer var reportOutput bytes.Buffer
_, reportWriteErr := report.WriteTo(&reportOutput) _, reportWriteErr := report.WriteTo(&reportOutput)

View File

@@ -6,7 +6,7 @@ import (
"io/fs" "io/fs"
"path" "path"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// CollectionBundleConfig is a representative application-owned collection // CollectionBundleConfig is a representative application-owned collection
@@ -86,7 +86,7 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
continue continue
} }
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file") contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
info, found := resolution.InputInfo(contentPath) info, found := resolution.EntryInfo(contentPath)
if !found || len(info.Origin.Locations) != 1 { if !found || len(info.Origin.Locations) != 1 {
return fmt.Errorf("prompt resource has no single declaring source") return fmt.Errorf("prompt resource has no single declaring source")
} }
@@ -104,9 +104,9 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
} }
prompt.ResourceText = string(data) prompt.ResourceText = string(data)
collection.Prompts[promptID] = prompt collection.Prompts[promptID] = prompt
resolution.Record( resolution.RecordNormalization(
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"), configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
configkit.ChangeNormalization, "read source-relative prompt content", contentPath, "read source-relative prompt content", contentPath,
) )
} }
configuration.Collections[collectionID] = collection configuration.Collections[collectionID] = collection

View File

@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_inputDestination() { func Example_inputDestination() {
@@ -60,7 +60,7 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
} }
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version")) versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 { if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerMain || len(versionInfo.Origin.Locations) != 1 {
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok) t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
} }
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version") collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")

View File

@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// FileComparisonConfig is a small application-owned shape for comparing // FileComparisonConfig is a small application-owned shape for comparing
@@ -101,8 +101,8 @@ func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resoluti
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port) url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
if endpoint.URL != url { if endpoint.URL != url {
endpoint.URL = url endpoint.URL = url
resolution.Record(configkit.Keys("service", "endpoint", "url"), resolution.RecordNormalization(configkit.Keys("service", "endpoint", "url"),
configkit.ChangeNormalization, "derived endpoint URL", "derived endpoint URL",
configkit.Keys("service", "endpoint", "host"), configkit.Keys("service", "endpoint", "host"),
configkit.Keys("service", "endpoint", "port")) configkit.Keys("service", "endpoint", "port"))
} }

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func ExampleCompareFiles() { func ExampleCompareFiles() {

View File

@@ -0,0 +1,46 @@
package consumers_test
import (
"fmt"
"os"
"path/filepath"
configkit "go.maximumdirect.net/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
}
source, _ := result.Source("source-1")
fmt.Println(result.Config.Name, filepath.Base(source.Path))
// Output: example service.yml
}

View File

@@ -1,6 +1,6 @@
package consumers package consumers
import configkit "gitea.maximumdirect.net/eric/ConfigKit" import configkit "go.maximumdirect.net/configkit"
// PresetCatalog is ordinary application data; no inheritance fields or // PresetCatalog is ordinary application data; no inheritance fields or
// registration hooks are required in the typed configuration. // registration hooks are required in the typed configuration.

View File

@@ -3,8 +3,8 @@ package consumers_test
import ( import (
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) { func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"io/fs" "io/fs"
"path" "path"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version //go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version

View File

@@ -6,8 +6,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_nativeFilesystem() { func Example_nativeFilesystem() {
@@ -26,7 +26,7 @@ func Example_rootRelativeImports() {
fmt.Println(err) fmt.Println(err)
return return
} }
source := result.Sources()[2] source, _ := result.Source("source-3")
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err) fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil> // Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
} }
@@ -68,7 +68,7 @@ func Example_sourceVersionAdoption() {
} }
checks, redacted := 0, true checks, redacted := 0, true
for _, source := range result.Sources() { for _, source := range result.Sources() {
if check := source.VersionCheck; check != nil { if check := source.VersionEvidence; check != nil {
checks++ checks++
redacted = redacted && check.Redacted && check.Version.Text == "" redacted = redacted && check.Redacted && check.Version.Text == ""
} }
@@ -95,7 +95,7 @@ func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
t.Fatalf("versioned service sources = %#v", sources) t.Fatalf("versioned service sources = %#v", sources)
} }
for _, source := range sources { for _, source := range sources {
check := source.VersionCheck check := source.VersionEvidence
if check == nil || check.Path.String() != "/version" || !check.Version.Present || if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
!check.Redacted || check.Version.Text != "" { !check.Redacted || check.Version.Text != "" {
t.Fatalf("safe source version evidence = %#v", sources) t.Fatalf("safe source version evidence = %#v", sources)
@@ -119,7 +119,7 @@ func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
} }
for _, key := range []string{"enabled", "workers"} { for _, key := range []string{"enabled", "workers"} {
info, found := result.Metadata().Lookup(configkit.Keys(key)) info, found := result.Metadata().Lookup(configkit.Keys(key))
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase { if !found || !info.Explicit || info.Origin.Layer != configkit.LayerMain {
t.Fatalf("%s presence = %#v, %v", key, info, found) t.Fatalf("%s presence = %#v, %v", key, info, found)
} }
} }

View File

@@ -3,7 +3,7 @@ package consumers
import ( import (
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// PromptCatalogConfig is an application-owned prompt collection shape. // PromptCatalogConfig is an application-owned prompt collection shape.

View File

@@ -8,8 +8,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_identifiedPromptCatalog() { func Example_identifiedPromptCatalog() {
@@ -24,8 +24,8 @@ func Example_identifiedPromptCatalog() {
} }
emptySearches := 0 emptySearches := 0
for _, source := range result.Sources() { for _, source := range result.Sources() {
for _, selection := range source.ImportSelections { for _, selection := range source.EmptyImportSelections {
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone { if selection.Selector == configkit.ImportSelectorDirectory {
emptySearches++ emptySearches++
} }
} }

View File

@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// SequenceCatalogConfig is a source-backed prompt catalog with an application // SequenceCatalogConfig is a source-backed prompt catalog with an application
@@ -31,28 +31,39 @@ 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.SearchPaths(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"),
}, },
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error { Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
configuration.ResolvedLabel = "for:" + configuration.RunLabel configuration.ResolvedLabel = "for:" + configuration.RunLabel
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization, resolution.RecordNormalization(configkit.Keys("resolved_label"),
"derive the resolved label from the selected run label", configkit.Keys("run_label")) "derive the resolved label from the selected run label", configkit.Keys("run_label"))
return nil return nil
}, },
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

@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_sequenceCatalog() { func Example_sequenceCatalog() {

View File

@@ -4,7 +4,7 @@ import (
"errors" "errors"
"io/fs" "io/fs"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// VersionedServiceConfig demonstrates a mounted main input whose source-local // VersionedServiceConfig demonstrates a mounted main input whose source-local
@@ -47,7 +47,7 @@ func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configk
return nil return nil
}, },
}, },
Secret: func(path configkit.Path) bool { Sensitive: func(path configkit.Path) bool {
return path.String() == "/service/version" return path.String() == "/service/version"
}, },
} }

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
var ( var (
@@ -23,7 +23,7 @@ var (
func TestFacadeValuesRemainRootOwned(t *testing.T) { func TestFacadeValuesRemainRootOwned(t *testing.T) {
t.Parallel() t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit" const packagePath = "go.maximumdirect.net/configkit"
for _, candidate := range []reflect.Type{ for _, candidate := range []reflect.Type{
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](), reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](), reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
@@ -140,7 +140,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
report, err := configkit.LoadSecretsDir(directory) report, err := configkit.LoadSecretsIntoEnvironment(directory)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -164,7 +164,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
t.Fatalf("report rendering = %q, %v", rendered.String(), err) t.Fatalf("report rendering = %q, %v", rendered.String(), err)
} }
failedReport, err := configkit.LoadSecretsDir("") failedReport, err := configkit.LoadSecretsIntoEnvironment("")
var secretsError *configkit.SecretsError var secretsError *configkit.SecretsError
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath { if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err) t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
@@ -203,7 +203,7 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
if !resolution.IsExplicit(configkit.Keys("input")) { if !resolution.IsExplicit(configkit.Keys("input")) {
resolution.Error(configkit.Keys("input"), "input is required") resolution.Error(configkit.Keys("input"), "input is required")
} }
input, ok := resolution.InputInfo(configkit.Keys("input")) input, ok := resolution.EntryInfo(configkit.Keys("input"))
if !ok || !input.Explicit { if !ok || !input.Explicit {
resolution.Error(configkit.Keys("input"), "missing input provenance") resolution.Error(configkit.Keys("input"), "missing input provenance")
} }
@@ -245,6 +245,8 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
} }
for _, context := range []func(){ for _, context := range []func(){
func() { retainedResolution.IsExplicit(configkit.Keys("input")) }, func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
func() { retainedResolution.RecordDerivedDefault(configkit.Keys("input"), "too late") },
func() { retainedResolution.RecordNormalization(configkit.Keys("input"), "too late") },
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") }, func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
} { } {
func() { func() {

View File

@@ -5,10 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
var ( var (
@@ -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 {

View File

@@ -0,0 +1,41 @@
package configkit_test
import (
"testing"
"testing/fstest"
configkit "go.maximumdirect.net/configkit"
)
func TestOverlayExplanationUsesQueryScopedOrigin(t *testing.T) {
t.Parallel()
filesystem := configkit.NewFilesystem("origin", fstest.MapFS{
"main.yml": {Data: []byte("a: base\ngroup: {x: X, y: Y}\n")},
"overlays/dev.yml": {Data: []byte("a: overlay\ngroup: {x: O}\n")},
})
result, err := configkit.Load[map[string]any](filesystem.File("main.yml"), configkit.Options[map[string]any]{Overlay: "dev"})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
path configkit.Path
line, column int
}{
{configkit.Keys(), 1, 1},
{configkit.Keys("group"), 2, 8},
{configkit.Keys("group", "x"), 2, 12},
} {
explanation, found := result.Explain(test.path)
if !found || len(explanation.History) == 0 {
t.Fatalf("missing explanation for %s", test.path)
}
origin := explanation.History[len(explanation.History)-1].Origin
if origin.Layer != configkit.LayerOverlay || len(origin.Locations) != 1 {
t.Fatalf("overlay origin for %s = %#v", test.path, origin)
}
location := origin.Locations[0]
if location.SourceID != "source-2" || location.Line != test.line || location.Column != test.column {
t.Fatalf("overlay location for %s = %#v; want line %d column %d", test.path, location, test.line, test.column)
}
}
}

View File

@@ -3,7 +3,7 @@ package configkit_test
import ( import (
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) { func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
@@ -24,10 +24,11 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
call func() call func()
}{ }{
{"IsExplicit", func() { state.resolution.IsExplicit(path) }}, {"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }}, {"EntryInfo", func() { state.resolution.EntryInfo(path) }},
{"Source", func() { state.resolution.Source("source") }}, {"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("source") }}, {"SourceContext", func() { state.resolution.SourceContext("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }}, {"RecordDerivedDefault", func() { state.resolution.RecordDerivedDefault(path, "reason") }},
{"RecordNormalization", func() { state.resolution.RecordNormalization(path, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }}, {"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }}, {"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},

View File

@@ -14,7 +14,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) { func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
@@ -352,7 +352,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error { Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
callbacks.resolve++ callbacks.resolve++
resourcePath := configkit.Keys("catalog", "entries", "item", "resource") resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
input, ok := resolution.InputInfo(resourcePath) input, ok := resolution.EntryInfo(resourcePath)
if !ok || len(input.Origin.Locations) != 1 { if !ok || len(input.Origin.Locations) != 1 {
return fmt.Errorf("mounted resource input has no single source origin") return fmt.Errorf("mounted resource input has no single source origin")
} }
@@ -370,11 +370,11 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
} }
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item") entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
entry["resource_text"] = string(data) entry["resource_text"] = string(data)
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath) resolution.RecordNormalization(configkit.Keys("catalog", "entries", "item", "resource_text"), "read source-relative summary", resourcePath)
return nil return nil
}, },
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ }, Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() }, Sensitive: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
}, },
}) })
if err != nil { if err != nil {
@@ -405,7 +405,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
if err != nil || string(data) != "source-relative summary" { if err != nil || string(data) != "source-relative summary" {
t.Fatalf("completed relative resource = %q, %v", data, err) t.Fatalf("completed relative resource = %q, %v", data, err)
} }
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase { if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerMain {
t.Fatalf("mounted title metadata = %#v, %v", info, ok) t.Fatalf("mounted title metadata = %#v, %v", info, ok)
} }
if history := result.Metadata().History(titlePath); len(history) < 2 { if history := result.Metadata().History(titlePath); len(history) < 2 {

View File

@@ -0,0 +1,95 @@
package configkit_test
import (
"bytes"
"errors"
"strings"
"testing"
"testing/fstest"
configkit "go.maximumdirect.net/configkit"
)
func TestFacadeCompositionConflictKeepsClaimantOrder(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"a.yml": &fstest.MapFile{Data: []byte(`configkit:
imports:
- path: z.yml
`)},
"b.yml": &fstest.MapFile{Data: []byte("x: b\n")},
"c.yml": &fstest.MapFile{Data: []byte("x: c\n")},
"z.yml": &fstest.MapFile{Data: []byte("x: z\n")},
})
main := configkit.Mapping("main", map[string]any{
"configkit": map[string]any{"imports": []any{
map[string]any{"path": "a.yml"},
map[string]any{"path": "b.yml"},
map[string]any{"path": "c.yml"},
}},
})
_, err := configkit.Load[map[string]any](main, configkit.Options[map[string]any]{
Imports: true,
Filesystem: &configkit.FilesystemContext{
FS: &filesystem, Root: ".", BaseDir: ".",
},
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("Load error = %v, want composition LoadError", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one composition conflict", diagnostics)
}
diagnostic := diagnostics[0]
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != "/x" || diagnostic.Primary == nil {
t.Fatalf("composition diagnostic = %#v", diagnostic)
}
pathsByID := make(map[string]string)
var acquisitionOrder []string
for _, source := range loadError.Sources() {
pathsByID[source.ID] = source.Path
for _, name := range []string{"z.yml", "b.yml", "c.yml"} {
if source.Path == name || strings.HasSuffix(source.Path, "/"+name) {
acquisitionOrder = append(acquisitionOrder, strings.TrimSuffix(name, ".yml"))
break
}
}
}
if strings.Join(acquisitionOrder, ",") != "z,b,c" {
t.Fatalf("claimant acquisition order = %v, want z,b,c", acquisitionOrder)
}
claimant := func(location configkit.Location) string {
for _, name := range []string{"b.yml", "c.yml", "z.yml"} {
if pathsByID[location.SourceID] == name || strings.HasSuffix(pathsByID[location.SourceID], "/"+name) {
return strings.TrimSuffix(name, ".yml")
}
}
return ""
}
if got := claimant(*diagnostic.Primary); got != "b" {
t.Fatalf("primary claimant = %q, want b (sources: %#v)", got, pathsByID)
}
if len(diagnostic.Related) != 2 || claimant(diagnostic.Related[0]) != "c" || claimant(diagnostic.Related[1]) != "z" {
t.Fatalf("related claimants = %#v, want c then z", diagnostic.Related)
}
var output bytes.Buffer
if err := configkit.WriteDiagnostics(&output, diagnostics); err != nil {
t.Fatal(err)
}
positions := []int{
strings.Index(output.String(), diagnostic.Primary.SourceID),
strings.Index(output.String(), diagnostic.Related[0].SourceID),
strings.Index(output.String(), diagnostic.Related[1].SourceID),
}
if positions[0] < 0 || positions[1] <= positions[0] || positions[2] <= positions[1] {
t.Fatalf("rendered claimant order = %v in %q", positions, output.String())
}
diagnostics[0].Related[0].SourceID = "mutated"
if got := claimant(loadError.Diagnostics()[0].Related[0]); got != "c" {
t.Fatalf("LoadError.Diagnostics retained caller mutation; first claimant = %q", got)
}
}

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type facadeDiffConfig struct { type facadeDiffConfig struct {
@@ -29,10 +29,10 @@ func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testin
t.Helper() t.Helper()
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")), result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Items[0] = "public scalar" current.Items[0] = "public scalar"
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item") resolution.RecordNormalization(configkit.Keys("items"), "normalize first item")
return nil return nil
}, },
}}) }})
@@ -71,10 +71,10 @@ func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
Dynamic map[string]any `yaml:"dynamic"` Dynamic map[string]any `yaml:"dynamic"`
} }
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
delete(current.Dynamic, "entry") delete(current.Dynamic, "entry")
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry") resolution.RecordNormalization(configkit.Keys("dynamic", "entry"), "remove entry")
return nil return nil
}, },
}} }}
@@ -221,7 +221,7 @@ func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
resolve := func(value string) configkit.Options[config] { resolve := func(value string) configkit.Options[config] {
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error { return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
current.Count = len(value) + 8 current.Count = len(value) + 8
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count") resolution.RecordNormalization(configkit.Keys("count"), "set effective count")
return nil return nil
}}} }}}
} }
@@ -281,7 +281,7 @@ func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) { func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
t.Setenv("FACADE_DIFF_SECRET", "credential-one") t.Setenv("FACADE_DIFF_SECRET", "credential-one")
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool { secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Sensitive: func(path configkit.Path) bool {
return path.String() == "/secret" return path.String() == "/secret"
}}} }}}
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions) left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)

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(SearchPaths(paths...), SearchDirectory(root, directoryOptions...), SearchPaths(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 = SearchPaths(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("", SearchPaths(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("", SearchPaths(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 != LayerMain ||
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("", SearchPaths(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("", SearchPaths(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("", SearchPaths(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("", SearchPaths(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.RecordNormalization(Keys("resolved"), "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("", SearchPaths(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("", SearchPaths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
Destination: options.Destination,
PlacementMode: options.PlacementMode,
})
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
}
}
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
for _, diagnostic := range err.Diagnostics() {
if diagnostic.Code == code {
return true
}
}
return false
}
func canonicalFacadePath(t *testing.T, path string) string {
t.Helper()
absolute, err := filepath.Abs(path)
if err != nil {
t.Fatal(err)
}
canonical, err := filepath.EvalSymlinks(absolute)
if err != nil {
t.Fatal(err)
}
return canonical
}

View File

@@ -0,0 +1,53 @@
package configkit_test
import (
"bytes"
"strings"
"testing"
configkit "go.maximumdirect.net/configkit"
)
func TestFinalDynamicTagRedactsEqualValuedResolution(t *testing.T) {
t.Parallel()
type classified struct {
Token string `yaml:"token" configkit:"secret"`
}
type configuration struct {
Value any `yaml:"value"`
}
const canary = "DYNAMIC-TAG-CANARY"
result, err := configkit.Load(configkit.Bytes("input", []byte("value: {token: "+canary+"}\n")), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{
Resolve: func(c *configuration, _ *configkit.Resolution) error {
// No canonical value changes, so no normalization record is needed.
c.Value = classified{Token: canary}
return nil
},
},
})
if err != nil {
t.Fatal(err)
}
var effective bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), canary) || !strings.Contains(effective.String(), "[REDACTED]") {
t.Fatal("effective rendering did not preserve the final dynamic tag")
}
explanation, found := result.Explain(configkit.Keys("value", "token"))
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
t.Fatal("effective explanation did not preserve the final dynamic tag")
}
if len(explanation.History) == 0 {
t.Fatal("source history is absent")
}
for _, change := range explanation.History {
for _, value := range []configkit.ValueSnapshot{change.Before, change.After} {
if value.Present && (!value.Redacted || strings.Contains(value.YAML, canary)) {
t.Fatal("historical explanation did not preserve the final dynamic tag")
}
}
}
}

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type equivalentConfig struct { type equivalentConfig struct {
@@ -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,
@@ -66,7 +69,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
} }
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} { for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
info, ok := result.Metadata().Lookup(configkit.Keys(field)) info, ok := result.Metadata().Lookup(configkit.Keys(field))
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") { if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerMain || info.Null != (field == "optional") {
t.Errorf("%s input info = %#v, %v", field, info, ok) t.Errorf("%s input info = %#v, %v", field, info, ok)
} }
} }
@@ -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) {
@@ -120,7 +132,7 @@ func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
} }
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{ result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}, Definition: configkit.Definition[config]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }},
}) })
if err != nil || result.Config.Secret != "new-secret-canary" { if err != nil || result.Config.Secret != "new-secret-canary" {
t.Fatalf("secret load = %#v, %v", result, err) t.Fatalf("secret load = %#v, %v", result, err)

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
//go:embed testdata/filesystem //go:embed testdata/filesystem

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit" "go.maximumdirect.net/configkit"
) )
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) { func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
@@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
if phase == "resolution" { if phase == "resolution" {
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items (*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload") resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
return nil return nil
} }
} }
@@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
case "resolution": case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items (*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records") resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
return nil return nil
} }
} }
@@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
case "resolution": case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}} (*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records") resolution.RecordNormalization(configkit.Keys("collections"), "create namespaced records")
return nil return nil
} }
} }

View File

@@ -0,0 +1,120 @@
package configkit_test
import (
"reflect"
"testing"
configkit "go.maximumdirect.net/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")
}
assertPublicShapeHistory(t, metadata, configkit.LayerCLI)
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")
}
if (configkit.Metadata{}).History(configkit.Keys("shape")) != nil {
t.Fatal("zero metadata contains history")
}
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 TestPublicHistoryUsesResolutionShapeAttribution(t *testing.T) {
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte("shape: {'0': base}\n")), configkit.Options[map[string]any]{
Definition: configkit.Definition[map[string]any]{
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
(*configuration)["shape"] = []any{"replacement"}
resolution.RecordNormalization(configkit.Keys("shape"), "replace numeric mapping key")
return nil
},
},
})
if err != nil {
t.Fatalf("Load: %v", err)
}
assertPublicShapeHistory(t, loaded.Metadata(), configkit.LayerResolution)
}
func assertPublicShapeHistory(t *testing.T, metadata configkit.Metadata, operationLayer configkit.Layer) {
t.Helper()
keyPath := configkit.Keys("shape", "0")
indexPath := configkit.Keys("shape").Index(0)
keyHistory := metadata.History(keyPath)
indexHistory := metadata.History(indexPath)
if len(keyHistory) == 0 || len(indexHistory) == 0 {
t.Fatalf("explicit shape histories = key %#v, index %#v", keyHistory, indexHistory)
}
var keyDefinition, keyReplacement, indexReplacement bool
for _, change := range keyHistory {
keyDefinition = keyDefinition || change.Origin.Layer == configkit.LayerMain
keyReplacement = keyReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
}
for _, change := range indexHistory {
if change.Origin.Layer == configkit.LayerMain {
t.Fatalf("introduced index inherited mapping-key history: %#v", indexHistory)
}
indexReplacement = indexReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
for _, previous := range change.Previous {
if previous.Layer == configkit.LayerMain {
t.Fatalf("introduced index inherited mapping-key previous origin: %#v", change)
}
}
}
if !keyDefinition || !keyReplacement || !indexReplacement {
t.Fatalf("shape histories missed applicable definitions or replacement: key %#v, index %#v", keyHistory, indexHistory)
}
parsed, err := configkit.ParsePath("/shape/0")
if err != nil {
t.Fatal(err)
}
if parsedHistory := metadata.History(parsed); len(parsedHistory) == 0 {
t.Fatal("parsed numeric query lost contextual history")
}
}
func pathStrings(paths []configkit.Path) []string {
result := make([]string, len(paths))
for index, path := range paths {
result[index] = path.String()
}
return result
}

View File

@@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) { func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {

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) {
@@ -95,7 +93,8 @@ main: main
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) { func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
type config struct { type config struct {
Value string `yaml:"value"` Value string `yaml:"value"`
Hidden string `yaml:"hidden"`
} }
directory := t.TempDir() directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit: mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
@@ -103,17 +102,23 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
- dir: snippets - dir: snippets
`) `)
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n") writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
writeFacadeImportFile(t, directory, "snippets/.hidden.cfg", "hidden: imported\n")
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true}) unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
if err != nil || unrestricted.Config.Value != "imported" { if err != nil || unrestricted.Config.Value != "imported" {
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err) t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
} }
includeHidden := false
visibleOnly, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{IncludeHidden: &includeHidden}})
if err != nil || visibleOnly.Config.Value != "imported" || visibleOnly.Config.Hidden != "" {
t.Fatalf("hidden-file default = %#v, %v", visibleOnly, err)
}
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}}) empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
if err != nil || empty.Config.Value != "" { if err != nil || empty.Config.Value != "" {
t.Fatalf("empty pattern = %#v, %v", empty, err) t.Fatalf("empty pattern = %#v, %v", empty, err)
} }
emptySources := empty.Sources() emptySources := empty.Sources()
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone { if len(emptySources) != 1 || len(emptySources[0].EmptyImportSelections) != 1 || emptySources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("empty-pattern evidence = %#v", emptySources) t.Fatalf("empty-pattern evidence = %#v", emptySources)
} }
required := true required := true
@@ -122,23 +127,64 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("required empty selection error = %T %v", err, err) t.Fatalf("required empty selection error = %T %v", err, err)
} }
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 { if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 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)
}
})
} }
} }
@@ -191,15 +237,15 @@ value: loaded
callbackPath := "" callbackPath := ""
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
first, ok := resolution.Source("source-1") first, ok := resolution.Source("source-1")
if !ok || len(first.ImportSelections) != 1 { if !ok || len(first.EmptyImportSelections) != 1 {
t.Fatalf("resolution source = %#v, %v", first, ok) t.Fatalf("resolution source = %#v, %v", first, ok)
} }
first.ImportSelections[0].Path = Keys("callback mutation") first.EmptyImportSelections[0].Path = Keys("callback mutation")
second, ok := resolution.Source("source-1") second, ok := resolution.Source("source-1")
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" { if !ok || second.EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("resolution snapshot changed after mutation: %#v", second) t.Fatalf("resolution snapshot changed after mutation: %#v", second)
} }
callbackPath = second.ImportSelections[0].Path.String() callbackPath = second.EmptyImportSelections[0].Path.String()
return nil return nil
}}}) }}})
if err != nil { if err != nil {
@@ -209,11 +255,11 @@ value: loaded
t.Fatalf("callback path = %q", callbackPath) t.Fatalf("callback path = %q", callbackPath)
} }
sources := loaded.Sources() sources := loaded.Sources()
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory { if len(sources) != 1 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Location.SourceID != sources[0].ID || sources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("result source evidence = %#v", sources) t.Fatalf("result source evidence = %#v", sources)
} }
sources[0].ImportSelections[0].Path = Keys("result mutation") sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("result snapshot changed after mutation: %q", got) t.Fatalf("result snapshot changed after mutation: %q", got)
} }
@@ -229,11 +275,11 @@ value: loaded
t.Fatalf("failed load error = %T %v", err, err) t.Fatalf("failed load error = %T %v", err, err)
} }
errorSources := loadError.Sources() errorSources := loadError.Sources()
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" { if len(errorSources) != 1 || len(errorSources[0].EmptyImportSelections) != 1 || errorSources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("error source evidence = %#v", errorSources) t.Fatalf("error source evidence = %#v", errorSources)
} }
errorSources[0].ImportSelections[0].Path = Keys("error mutation") errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("error snapshot changed after mutation: %q", got) t.Fatalf("error snapshot changed after mutation: %q", got)
} }
} }

View File

@@ -42,7 +42,7 @@ entries:
Definition: Definition[map[string]any]{ Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")}, SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
Secret: func(candidate Path) bool { Sensitive: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version" return candidate.String() == "/services/records/0/version"
}, },
}, },
@@ -67,6 +67,14 @@ entries:
} }
sources := result.Sources() sources := result.Sources()
if len(sources) < 3 || sources[0].Layer != LayerMain || string(sources[0].Layer) != "main" {
t.Fatalf("main source attribution = %#v", sources)
}
for _, source := range sources[1:] {
if source.ImportedFrom != nil && (source.Layer != LayerMain || string(source.Layer) != "main") {
t.Errorf("imported main source attribution = %#v", source)
}
}
filesByID := make(map[string]string, len(sources)) filesByID := make(map[string]string, len(sources))
for _, source := range sources { for _, source := range sources {
filesByID[source.ID] = source.Path filesByID[source.ID] = source.Path
@@ -77,8 +85,8 @@ entries:
itemSource = source itemSource = source
} }
} }
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" { if itemSource.VersionEvidence == nil || !itemSource.VersionEvidence.Version.Present || !itemSource.VersionEvidence.Redacted || itemSource.VersionEvidence.Version.Text != "" {
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck) t.Fatalf("consumed header inspection = %#v", itemSource.VersionEvidence)
} }
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id")) idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" { if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
@@ -107,7 +115,7 @@ entries:
Definition: Definition[map[string]any]{ Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")}, SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
Secret: func(candidate Path) bool { Sensitive: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version" return candidate.String() == "/services/records/0/version"
}, },
}, },
@@ -129,7 +137,7 @@ entries:
break break
} }
} }
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted { if !foundFailedItem || failedItemSource.VersionEvidence == nil || failedItemSource.VersionEvidence.Version.Text != "" || !failedItemSource.VersionEvidence.Redacted {
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource) t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
} }
} }

View File

@@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) { func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type inheritedEntry struct { type inheritedEntry struct {
@@ -42,7 +42,7 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) { if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
t.Error("inherited false is not explicit") t.Error("inherited false is not explicit")
} }
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled")) info, found := resolution.EntryInfo(configkit.Keys("entries", "child", "enabled"))
if !found || len(info.Origin.Inheritance) != 1 { if !found || len(info.Origin.Inheritance) != 1 {
t.Errorf("resolver inherited origin = %#v, %v", info, found) t.Errorf("resolver inherited origin = %#v, %v", info, found)
} }
@@ -139,7 +139,7 @@ func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testin
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{ result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{ Definition: configkit.Definition[configuration]{
SequenceIdentities: registrations, SequenceIdentities: registrations,
Secret: func(path configkit.Path) bool { Sensitive: func(path configkit.Path) bool {
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential" return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
}, },
}, },
@@ -221,7 +221,7 @@ func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{ _, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{ Definition: configkit.Definition[configuration]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")}, SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
}, },
}) })
if err == nil || strings.Contains(err.Error(), canary) { if err == nil || strings.Contains(err.Error(), canary) {
@@ -468,7 +468,7 @@ func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
Entries map[string]entry `yaml:"entries"` Entries map[string]entry `yaml:"entries"`
} }
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
}}) }})
if err != nil || result.Config.Entries["child"].Credential.Value() != canary { if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
t.Fatalf("inherited credential = %#v, %v", result, err) t.Fatalf("inherited credential = %#v, %v", result, 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

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) { func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) { func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {

View File

@@ -10,7 +10,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type layeredFacadeConfig struct { type layeredFacadeConfig struct {
@@ -59,8 +59,8 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native")) configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
return nil return nil
}, },
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ }, Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
}, },
}) })
if err != nil { if err != nil {
@@ -80,7 +80,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
if len(sources) != 8 { if len(sources) != 8 {
t.Fatalf("sources = %#v", sources) t.Fatalf("sources = %#v", sources)
} }
wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI} wantLayers := []configkit.Layer{configkit.LayerMain, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0} wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI} wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
for index, source := range sources { for index, source := range sources {

View File

@@ -7,7 +7,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type mappingFacadeConfig struct { type mappingFacadeConfig struct {
@@ -36,7 +36,7 @@ func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
if !reflect.DeepEqual(result.Config, want) { if !reflect.DeepEqual(result.Config, want) {
t.Fatalf("mapping config = %#v, want %#v", result.Config, want) t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
} }
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" { if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerMain || sources[0].Path != "" {
t.Fatalf("mapping sources = %#v", sources) t.Fatalf("mapping sources = %#v", sources)
} }
info, ok := result.Metadata().Lookup(configkit.Keys("name")) info, ok := result.Metadata().Lookup(configkit.Keys("name"))
@@ -89,7 +89,7 @@ func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled { if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
t.Fatalf("mapping import = %#v, %v", result, err) t.Fatalf("mapping import = %#v, %v", result, err)
} }
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase { if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerMain {
t.Fatalf("mapping import sources = %#v", sources) t.Fatalf("mapping import sources = %#v", sources)
} }
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid) checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)

View File

@@ -0,0 +1,63 @@
package configkit_test
import (
"errors"
"testing"
configkit "go.maximumdirect.net/configkit"
)
func TestFacadeResolutionPresenceUsesDynamicEntryShape(t *testing.T) {
t.Parallel()
type configuration struct {
Shape any `yaml:"shape"`
}
tests := []struct {
name string
input string
keyPresent bool
}{
{name: "mapping key", input: "shape: {'0': input}\n", keyPresent: true},
{name: "sequence index", input: "shape: [input]\n", keyPresent: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parsed, err := configkit.ParsePath("/shape/0")
if err != nil {
t.Fatal(err)
}
_, err = configkit.Load(configkit.Bytes("dynamic.yml", []byte(test.input)), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{
Resolve: func(_ *configuration, resolution *configkit.Resolution) error {
check := func(path configkit.Path, want bool) error {
info, found := resolution.EntryInfo(path)
explicit := resolution.IsExplicit(path)
if found != want || explicit != want || (found && info.Explicit != explicit) {
return errors.New("resolution presence disagrees with dynamic entry shape")
}
return nil
}
for _, checkPath := range []struct {
path configkit.Path
want bool
}{
{path: configkit.Keys("shape", "0"), want: test.keyPresent},
{path: configkit.Keys("shape").Index(0), want: !test.keyPresent},
{path: parsed, want: true},
{path: configkit.Keys("shape", "missing"), want: false},
} {
if err := check(checkPath.path, checkPath.want); err != nil {
return err
}
}
return nil
},
},
})
if err != nil {
t.Fatal(err)
}
})
}
}

View File

@@ -0,0 +1,43 @@
package configkit
import "testing"
func TestFacadeResolutionRecordingIntents(t *testing.T) {
type configuration struct {
Derived string `yaml:"derived"`
Normalized string `yaml:"normalized"`
}
result, err := Load(Bytes("recording.yml", []byte("derived: explicit\nnormalized: before\n")), Options[configuration]{
Definition: Definition[configuration]{Resolve: func(value *configuration, resolution *Resolution) error {
value.Derived = "changed explicit input"
resolution.RecordDerivedDefault(Keys("derived"), "derive explicit input")
value.Normalized = "after"
resolution.RecordNormalization(Keys("normalized"), "normalize configured input")
return nil
}},
})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
path Path
kind ChangeKind
}{
{path: Keys("derived"), kind: ChangeDerivedDefault},
{path: Keys("normalized"), kind: ChangeNormalization},
} {
info, ok := result.Metadata().Lookup(test.path)
if !ok || info.Origin.Operation != test.kind {
t.Errorf("metadata %s = %#v, %v; want operation %q", test.path, info, ok, test.kind)
}
history := result.Metadata().History(test.path)
if len(history) == 0 || history[len(history)-1].Origin.Operation != test.kind {
t.Errorf("history %s = %#v; want final operation %q", test.path, history, test.kind)
}
}
if !result.Metadata().IsExplicit(Keys("derived")) {
t.Fatal("recording a derived default suppressed explicit-input provenance")
}
}

View File

@@ -12,7 +12,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type observedResourceFS struct { type observedResourceFS struct {

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) { func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) { func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
@@ -125,7 +125,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
t.Fatalf("removal not explained: %#v", explanation) t.Fatalf("removal not explained: %#v", explanation)
} }
last := explanation.History[len(explanation.History)-1] last := explanation.History[len(explanation.History)-1]
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerBase || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID { if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerMain || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
t.Fatalf("wrong removal transition/origin: %#v", last) t.Fatalf("wrong removal transition/origin: %#v", last)
} }
}) })
@@ -211,7 +211,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" { if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
t.Fatalf("ordinary item placement = %#v", ordinary.Config) t.Fatalf("ordinary item placement = %#v", ordinary.Config)
} }
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved { if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationIsFallback {
t.Fatalf("ordinary item source = %#v", sources) t.Fatalf("ordinary item source = %#v", sources)
} }
@@ -230,7 +230,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
t.Fatalf("layered item placement = %#v", layered.Config) t.Fatalf("layered item placement = %#v", layered.Config)
} }
for _, source := range layered.Sources() { for _, source := range layered.Sources() {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved { if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
t.Errorf("layered item source = %#v", source) t.Errorf("layered item source = %#v", source)
} }
} }
@@ -334,7 +334,7 @@ items:
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts) t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
} }
for index, source := range callbackSources { for index, source := range callbackSources {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved { if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationIsFallback {
t.Errorf("callback source = %#v", source) t.Errorf("callback source = %#v", source)
} }
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" { if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
@@ -349,7 +349,7 @@ items:
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok) t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
} }
} }
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved { if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationIsFallback {
t.Fatalf("final item sources = %#v", got) t.Fatalf("final item sources = %#v", got)
} }
} }
@@ -372,9 +372,9 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
t.Fatalf("early failure = %v", err) t.Fatalf("early failure = %v", err)
} }
sources := loadError.Sources() sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved || if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved || sources[1].Destination.String() != "/items" || !sources[1].DestinationIsFallback ||
sources[1].PlacementMode != configkit.PlacementModeMapping { sources[1].PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("early source snapshots = %#v", sources) t.Fatalf("early source snapshots = %#v", sources)
} }
@@ -399,13 +399,13 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
t.Fatalf("post-placement failure = %v", err) t.Fatalf("post-placement failure = %v", err)
} }
sources = loadError.Sources() sources = loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved || if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved { sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationIsFallback {
t.Fatalf("post-placement source snapshots = %#v", sources) t.Fatalf("post-placement source snapshots = %#v", sources)
} }
firstSnapshot := sources[1] firstSnapshot := sources[1]
sources[1].Destination = configkit.Keys("mutated") sources[1].Destination = configkit.Keys("mutated")
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved { if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationIsFallback {
t.Fatalf("failure source snapshots were aliased: %#v", again) t.Fatalf("failure source snapshots were aliased: %#v", again)
} }
@@ -420,7 +420,7 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("successful item load: %v", err) t.Fatalf("successful item load: %v", err)
} }
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping { if callback.Destination.String() != "/items/0/metadata" || callback.DestinationIsFallback || callback.PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("callback source snapshot = %#v", callback) t.Fatalf("callback source snapshot = %#v", callback)
} }
} }
@@ -450,13 +450,13 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
if len(sources) != 3 { if len(sources) != 3 {
t.Fatalf("nested source snapshots = %#v", sources) t.Fatalf("nested source snapshots = %#v", sources)
} }
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem { if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeSequenceItem {
t.Errorf("outer item snapshot = %#v", got) t.Errorf("outer item snapshot = %#v", got)
} }
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem { if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeSequenceItem {
t.Errorf("nested item fallback = %#v", got) t.Errorf("nested item fallback = %#v", got)
} }
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping { if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeMapping {
t.Errorf("nested helper fallback = %#v", got) t.Errorf("nested helper fallback = %#v", got)
} }
} }
@@ -496,7 +496,7 @@ func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) { func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
zero := configkit.SourceInfo{} zero := configkit.SourceInfo{}
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved { if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationIsFallback {
t.Fatalf("zero source info = %#v", zero) t.Fatalf("zero source info = %#v", zero)
} }
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true})) loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
@@ -504,12 +504,12 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sources := loaded.Sources() sources := loaded.Sources()
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved { if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationIsFallback {
t.Fatalf("mapping source evidence = %#v", sources) t.Fatalf("mapping source evidence = %#v", sources)
} }
first := sources[0] first := sources[0]
first.Destination = configkit.Keys("caller") first.Destination = configkit.Keys("caller")
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved { if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationIsFallback {
t.Fatalf("source evidence changed after caller mutation: %#v", again) t.Fatalf("source evidence changed after caller mutation: %#v", again)
} }
} }
@@ -598,7 +598,7 @@ entries:
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
Definition: configkit.Definition[configuration]{ Definition: configkit.Definition[configuration]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")}, SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
Secret: func(path configkit.Path) bool { Sensitive: func(path configkit.Path) bool {
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token" return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
}, },
Resolve: func(value *configuration, resolution *configkit.Resolution) error { Resolve: func(value *configuration, resolution *configkit.Resolution) error {
@@ -611,8 +611,8 @@ entries:
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) { if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
t.Error("resolver treated an omitted field as explicit") t.Error("resolver treated an omitted field as explicit")
} }
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count")) info, found := resolution.EntryInfo(configkit.Keys("entries").Index(0).Key("count"))
if !found || info.Origin.Layer != configkit.LayerBase { if !found || info.Origin.Layer != configkit.LayerMain {
t.Errorf("resolver current-record input info = %#v, %v", info, found) t.Errorf("resolver current-record input info = %#v, %v", info, found)
} }
for _, id := range []string{"source-3", "source-4"} { for _, id := range []string{"source-3", "source-4"} {
@@ -646,7 +646,7 @@ entries:
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts) t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
} }
for _, source := range callbackItems { for _, source := range callbackItems {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved { if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
t.Errorf("callback item source = %#v", source) t.Errorf("callback item source = %#v", source)
} }
} }

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type movedVersionItem struct { type movedVersionItem struct {
@@ -64,7 +64,7 @@ func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
t.Fatal("unrelated underlay payload was treated as the main declaration") t.Fatal("unrelated underlay payload was treated as the main declaration")
} }
for _, source := range loaded.Sources() { for _, source := range loaded.Sources() {
if source.Layer == configkit.LayerBase && source.VersionCheck.Path.String() != path.String() { if source.Layer == configkit.LayerMain && source.VersionEvidence.Path.String() != path.String() {
t.Fatal("effective movement changed source-local version evidence") t.Fatal("effective movement changed source-local version evidence")
} }
} }

View File

@@ -10,7 +10,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) { func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
@@ -25,7 +25,7 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
definition := configkit.Definition[map[string]any]{ definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}, SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath }, Sensitive: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
} }
first := "records: [{id: first, version: first-version}]\n" first := "records: [{id: first, version: first-version}]\n"
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n" child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
@@ -61,7 +61,7 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
} }
} }
sources := result.Sources() sources := result.Sources()
check := sources[len(sources)-1].VersionCheck check := sources[len(sources)-1].VersionEvidence
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID { if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
t.Fatalf("source-local evidence changed: %#v", check) t.Fatalf("source-local evidence changed: %#v", check)
} }
@@ -209,7 +209,7 @@ func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
continue continue
} }
foundMapping = true foundMapping = true
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" { if check := source.VersionEvidence; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse) t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
} }
} }
@@ -265,7 +265,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")}, {"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
} { } {
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} { for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
var callbackEvidence *configkit.SourceVersionCheck var callbackEvidence *configkit.SourceVersionEvidence
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{ loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
SourceVersionMode: mode, SourceVersionMode: mode,
Definition: configkit.Definition[map[string]any]{ Definition: configkit.Definition[map[string]any]{
@@ -275,7 +275,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
if !found { if !found {
t.Fatal("missing callback source") t.Fatal("missing callback source")
} }
callbackEvidence = source.VersionCheck callbackEvidence = source.VersionEvidence
return nil return nil
}, },
}, },
@@ -283,7 +283,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"), Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
}}}}, }}}},
}) })
var evidence *configkit.SourceVersionCheck var evidence *configkit.SourceVersionEvidence
if mode == configkit.SourceVersionModeRequired { if mode == configkit.SourceVersionModeRequired {
var failure *configkit.LoadError var failure *configkit.LoadError
if loaded != nil || !errors.As(err, &failure) { if loaded != nil || !errors.As(err, &failure) {
@@ -293,12 +293,12 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() { if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics) t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
} }
evidence = failure.Sources()[1].VersionCheck evidence = failure.Sources()[1].VersionEvidence
} else { } else {
if err != nil { if err != nil {
t.Fatalf("%s: %v", test.name, err) t.Fatalf("%s: %v", test.name, err)
} }
evidence = loaded.Sources()[1].VersionCheck evidence = loaded.Sources()[1].VersionEvidence
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() { if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
t.Errorf("%s: callback path is incomplete", test.name) t.Errorf("%s: callback path is incomplete", test.name)
} }
@@ -365,7 +365,7 @@ func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
if child["value"] != "good" { if child["value"] != "good" {
t.Fatal("valid sibling inheritance was not applied") t.Fatal("valid sibling inheritance was not applied")
} }
check := loaded.Sources()[1].VersionCheck check := loaded.Sources()[1].VersionEvidence
if check == nil || check.Version.Present || check.Path.String() != path.String() { if check == nil || check.Version.Present || check.Path.String() != path.String() {
t.Fatal("optional omission evidence was not retained") t.Fatal("optional omission evidence was not retained")
} }
@@ -544,16 +544,16 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
var callbackSource configkit.SourceInfo var callbackSource configkit.SourceInfo
var callbackContext configkit.ResourceContext var callbackContext configkit.ResourceContext
definition := configkit.Definition[map[string]any]{ definition := configkit.Definition[map[string]any]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" }, Sensitive: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
var found bool var found bool
callbackSource, found = resolution.Source("source-1") callbackSource, found = resolution.Source("source-1")
if !found { if !found {
t.Fatal("resolution callback could not query the acquired source") t.Fatal("resolution callback could not query the acquired source")
} }
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" { if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck) t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionEvidence)
} }
callbackContext, found = resolution.SourceContext("source-1") callbackContext, found = resolution.SourceContext("source-1")
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." { if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
@@ -568,17 +568,17 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
if err != nil || loaded == nil { if err != nil || loaded == nil {
t.Fatalf("LoadLayered = %#v, %v", loaded, err) t.Fatalf("LoadLayered = %#v, %v", loaded, err)
} }
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" { if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck) t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionEvidence)
} }
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback") callbackSource.VersionEvidence.Path = configkit.Keys("mutated-callback")
callbackSource.VersionCheck.Version.Text = canary callbackSource.VersionEvidence.Version.Text = canary
callbackSource.VersionCheck.Location.Line = 98 callbackSource.VersionEvidence.Location.Line = 98
finalSources := loaded.Sources() finalSources := loaded.Sources()
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil { if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionEvidence == nil {
t.Fatalf("final sources = %#v", finalSources) t.Fatalf("final sources = %#v", finalSources)
} }
check := finalSources[0].VersionCheck check := finalSources[0].VersionEvidence
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted || if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 { check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
t.Fatalf("final classified evidence = %#v", check) t.Fatalf("final classified evidence = %#v", check)
@@ -594,9 +594,9 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
check.Redacted = false check.Redacted = false
check.Version.Text = "mutated" check.Version.Text = "mutated"
repeated := loaded.Sources() repeated := loaded.Sources()
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" || if len(repeated) != 1 || repeated[0].VersionEvidence.Path.String() != "/version" ||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted || repeated[0].VersionEvidence.Location.Line != 1 || !repeated[0].VersionEvidence.Redacted ||
repeated[0].VersionCheck.Version.Text != "" { repeated[0].VersionEvidence.Version.Text != "" {
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated) t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
} }
@@ -612,14 +612,14 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
}, },
}, },
}) })
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil { if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionEvidence == nil {
t.Fatalf("unclassified source evidence = %#v, %v", public, err) t.Fatalf("unclassified source evidence = %#v, %v", public, err)
} }
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present { if got := public.Sources()[0].VersionEvidence; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
t.Fatalf("unclassified evidence = %#v", got) t.Fatalf("unclassified evidence = %#v", got)
} }
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" { if publicCallback.VersionEvidence == nil || !publicCallback.VersionEvidence.Redacted || publicCallback.VersionEvidence.Version.Text != "" {
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck) t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionEvidence)
} }
} }
@@ -650,7 +650,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
if checked != 1 { if checked != 1 {
t.Fatalf("main declaration checker calls = %d; error %v", checked, err) t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
} }
check := failure.Sources()[0].VersionCheck check := failure.Sources()[0].VersionEvidence
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" { if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources()) t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
} }
@@ -666,14 +666,14 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
t.Fatalf("malformed version error = %v", err) t.Fatalf("malformed version error = %v", err)
} }
sources := loadError.Sources() sources := loadError.Sources()
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted || if len(sources) != 1 || sources[0].VersionEvidence == nil || !sources[0].VersionEvidence.Redacted ||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" { !sources[0].VersionEvidence.Version.Present || sources[0].VersionEvidence.Version.Text != "" || sources[0].VersionEvidence.Path.String() != "/version" {
t.Fatalf("failed-load source evidence = %#v", sources) t.Fatalf("failed-load source evidence = %#v", sources)
} }
sources[0].VersionCheck.Path = configkit.Keys("mutated-error") sources[0].VersionEvidence.Path = configkit.Keys("mutated-error")
sources[0].VersionCheck.Version.Text = "mutated" sources[0].VersionEvidence.Version.Text = "mutated"
sources[0].VersionCheck.Location.Line = 99 sources[0].VersionEvidence.Location.Line = 99
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 { if again := loadError.Sources()[0].VersionEvidence; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
t.Fatalf("LoadError source evidence was aliased: %#v", again) t.Fatalf("LoadError source evidence was aliased: %#v", again)
} }
for _, test := range []struct { for _, test := range []struct {
@@ -707,7 +707,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
if strings.Contains(err.Error(), test.canary) { if strings.Contains(err.Error(), test.canary) {
t.Fatalf("late failure disclosed source-version canary: %v", err) t.Fatalf("late failure disclosed source-version canary: %v", err)
} }
check := failure.Sources()[0].VersionCheck check := failure.Sources()[0].VersionEvidence
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" { if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check) t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
} }
@@ -715,7 +715,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
} }
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{}) defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil { if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionEvidence != nil {
t.Fatalf("default source version check = %#v, %v", defaultResult, err) t.Fatalf("default source version check = %#v, %v", defaultResult, err)
} }
@@ -728,7 +728,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
} }
sawCLI := false sawCLI := false
for _, source := range withCLI.Sources() { for _, source := range withCLI.Sources() {
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil { if source.Kind == configkit.SourceKindCLI && source.VersionEvidence != nil {
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source) t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
} }
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
@@ -764,14 +764,14 @@ func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked) t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
} }
sources := loaded.Sources() sources := loaded.Sources()
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil { if len(sources) != 3 || sources[1].VersionEvidence == nil || sources[2].VersionEvidence == nil {
t.Fatalf("source version evidence = %#v", sources) t.Fatalf("source version evidence = %#v", sources)
} }
absent := sources[1].VersionCheck absent := sources[1].VersionEvidence
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted { if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
t.Fatalf("optional omission evidence = %#v", absent) t.Fatalf("optional omission evidence = %#v", absent)
} }
null := sources[2].VersionCheck null := sources[2].VersionEvidence
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" { if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
t.Fatalf("null declaration evidence = %#v", null) t.Fatalf("null declaration evidence = %#v", null)
} }
@@ -807,7 +807,7 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
}, },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Service.Resolved = "resolved once" current.Service.Resolved = "resolved once"
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value") resolution.RecordNormalization(configkit.Keys("service", "resolved"), "derive resolved value")
return nil return nil
}, },
Validate: func(current *configuration, validation *configkit.Validation) { Validate: func(current *configuration, validation *configkit.Validation) {
@@ -865,8 +865,8 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String()) t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
} }
sourcesA, sourcesB := first.Sources(), second.Sources() sourcesA, sourcesB := first.Sources(), second.Sources()
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil || if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionEvidence == nil || sourcesB[1].VersionEvidence == nil ||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" { sourcesA[1].VersionEvidence.Version.Text != "underlay-release-a" || sourcesB[1].VersionEvidence.Version.Text != "underlay-release-b" {
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB) t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
} }
versionExplanation, found := first.Explain(configkit.Keys("service", "version")) versionExplanation, found := first.Explain(configkit.Keys("service", "version"))

View File

@@ -0,0 +1,50 @@
package configkit_test
import (
"testing"
configkit "go.maximumdirect.net/configkit"
)
func TestTierHistoryRecordsEachContributionOnce(t *testing.T) {
t.Parallel()
for _, middle := range []string{"mid", "low"} {
t.Run(middle, func(t *testing.T) {
result, err := configkit.LoadLayered[map[string]any](
configkit.Input{Source: configkit.Bytes("main", []byte("x: high\n"))},
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: configkit.Bytes("low", []byte("x: low\n"))}}},
{Inputs: []configkit.Input{{Source: configkit.Bytes("middle", []byte("x: "+middle+"\n"))}}},
}},
)
if err != nil {
t.Fatal(err)
}
history := result.Metadata().History(configkit.Keys("x"))
if len(history) != 3 {
t.Fatalf("history has %d records; want the three independent contributions", len(history))
}
explanation, found := result.Explain(configkit.Keys("x"))
if !found || len(explanation.History) != len(history) {
t.Fatal("explanation lost ordered tier contributions")
}
for index, sourceID := range []string{"source-2", "source-3", "source-1"} {
change := history[index]
if len(change.Origin.Locations) != 1 || change.Origin.Locations[0].SourceID != sourceID {
t.Fatalf("contribution %d has origin %#v", index, change.Origin)
}
if len(explanation.History[index].Origin.Locations) != 1 || explanation.History[index].Origin.Locations[0] != change.Origin.Locations[0] {
t.Fatalf("explanation contribution %d has a different source origin", index)
}
if index == 0 {
if len(change.Previous) != 0 {
t.Fatal("first source contribution has a previous origin")
}
} else if len(change.Previous) != 1 || len(change.Previous[0].Locations) != 1 ||
change.Previous[0].Locations[0] != history[index-1].Origin.Locations[0] {
t.Fatalf("contribution %d has incorrect previous origin %#v", index, change.Previous)
}
}
})
}
}

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestUnderlaySequenceDescendantOrigins(t *testing.T) { func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
@@ -31,7 +31,7 @@ func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
for _, pointer := range paths { for _, pointer := range paths {
path, _ := configkit.ParsePath(pointer) path, _ := configkit.ParsePath(pointer)
info, ok := resolution.InputInfo(path) info, ok := resolution.EntryInfo(path)
if !ok { if !ok {
t.Errorf("missing resolver input %s", pointer) t.Errorf("missing resolver input %s", pointer)
} }

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type versionCountingReader struct { type versionCountingReader struct {

4
go.mod
View File

@@ -1,11 +1,11 @@
module gitea.maximumdirect.net/eric/ConfigKit module go.maximumdirect.net/configkit
go 1.26.0 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

@@ -44,10 +44,10 @@ func TestDependencyImportBoundary(t *testing.T) {
production := !strings.HasSuffix(path, "_test.go") production := !strings.HasSuffix(path, "_test.go")
rootProduction := production && filepath.Dir(path) == "." rootProduction := production && filepath.Dir(path) == "."
internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/") internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/")
if rootProduction && strings.HasPrefix(importPath, "gitea.maximumdirect.net/eric/ConfigKit/internal/") && importPath != "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" { if rootProduction && strings.HasPrefix(importPath, "go.maximumdirect.net/configkit/internal/") && importPath != "go.maximumdirect.net/configkit/internal/engine" {
t.Errorf("%s bypasses the engine boundary through %s", path, importPath) t.Errorf("%s bypasses the engine boundary through %s", path, importPath)
} }
if internalProduction && importPath == "gitea.maximumdirect.net/eric/ConfigKit" { if internalProduction && importPath == "go.maximumdirect.net/configkit" {
t.Errorf("%s imports the public root package", path) t.Errorf("%s imports the public root package", path)
} }
} }

View File

@@ -3,7 +3,7 @@ package configkit
import ( import (
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine" "go.maximumdirect.net/configkit/internal/engine"
) )
// ValueSnapshot is a detached, deterministic YAML representation of one value. // ValueSnapshot is a detached, deterministic YAML representation of one value.

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

@@ -4,7 +4,7 @@ import (
"strconv" "strconv"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func BenchmarkComposeWideMappings(b *testing.B) { func BenchmarkComposeWideMappings(b *testing.B) {

View File

@@ -20,9 +20,9 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
Normalize(r, Keys("map"), &c.Map, shared, "populate") Normalize(r, Keys("map"), &c.Map, shared, "populate")
c.Pointer = external c.Pointer = external
r.Record(Keys("pointer"), ChangeNormalization, "populate") r.RecordNormalization(Keys("pointer"), "populate")
c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET") c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET")
r.Record(Keys("secret"), ChangeNormalization, "reference") r.RecordNormalization(Keys("secret"), "reference")
return nil return nil
}} }}
if validatorAssignment { if validatorAssignment {

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

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) { func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) {

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

@@ -3,7 +3,7 @@ package engine
import ( import (
"sort" "sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type collectionPolicyScope struct { type collectionPolicyScope struct {

View File

@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) { func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) {

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

@@ -1,12 +1,29 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "go.maximumdirect.net/configkit/internal/document"
// A true entry replaces the collection itself as well as its descendants; // A true entry replaces the collection itself as well as its descendants;
// false records ordinary ancestor replacement without selecting a collection // false records ordinary ancestor replacement without selecting a collection
// 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"
"go.maximumdirect.net/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

@@ -3,7 +3,7 @@ package engine
import ( import (
"maps" "maps"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
// composedDocument is the private handoff between source assembly and later // composedDocument is the private handoff between source assembly and later
@@ -43,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;
@@ -215,7 +223,7 @@ func composeDocumentsWithCollectionPolicy(
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned) rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
} }
for sourceIndex, input := range inputs { for sourceIndex, input := range inputs {
application := historyApplication{layer: LayerBase, value: input} application := historyApplication{layer: LayerMain, value: input}
for _, assigned := range rangesBySource[sourceIndex] { for _, assigned := range rangesBySource[sourceIndex] {
pointer := assigned.path.String() pointer := assigned.path.String()
mode := sequenceHistoryAppend mode := sequenceHistoryAppend
@@ -247,7 +255,7 @@ func composeDocumentsWithCollectionPolicy(
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) { func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
pointer := path.String() pointer := path.String()
if _, present := origins[pointer]; !present { if _, present := origins[pointer]; !present {
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}} origins[pointer] = Origin{Layer: LayerMain, Locations: []Location{publicLocation(node.Location)}}
} }
switch node.Kind { switch node.Kind {
case document.Mapping: case document.Mapping:
@@ -305,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())
} }
@@ -327,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)
@@ -358,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)
@@ -367,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}
} }
@@ -483,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 {
@@ -527,7 +513,7 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
walkHistoryApplication(application, func(_, effective Path, node *document.Node) { walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
pointer := effective.String() pointer := effective.String()
origin := origins[pointer] origin := origins[pointer]
origin.Layer = LayerBase origin.Layer = LayerMain
origin.Locations = append(origin.Locations, publicLocation(node.Location)) origin.Locations = append(origin.Locations, publicLocation(node.Location))
origins[pointer] = origin origins[pointer] = origin
}) })

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) { func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
@@ -92,7 +92,7 @@ values: [one, two]
{SourceID: "source-1", Line: 2, Column: 3}, {SourceID: "source-1", Line: 2, Column: 3},
{SourceID: "source-2", Line: 2, Column: 3}, {SourceID: "source-2", Line: 2, Column: 3},
} }
if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerBase, Locations: wantLocations}) { if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerMain, Locations: wantLocations}) {
t.Fatalf("services origin = %#v, want composite origin", serviceOrigin) t.Fatalf("services origin = %#v, want composite origin", serviceOrigin)
} }
wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"} wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"}
@@ -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

@@ -5,8 +5,8 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" yamladapter "go.maximumdirect.net/configkit/internal/yaml"
) )
func FuzzCompositionAndOverlay(f *testing.F) { func FuzzCompositionAndOverlay(f *testing.F) {
@@ -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

@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"time" "time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
@@ -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 {
@@ -437,7 +448,7 @@ func collectPresence(node *document.Node, path Path, presence map[string]inputPr
func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) { func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
if input, ok := presence[path.String()]; ok { if input, ok := presence[path.String()]; ok {
origins[path.String()] = Origin{Layer: LayerBase, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}} origins[path.String()] = Origin{Layer: LayerMain, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
} else { } else {
origins[path.String()] = Origin{Layer: LayerDefault} origins[path.String()] = Origin{Layer: LayerDefault}
} }

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type namedSmall int8 type namedSmall int8
@@ -141,7 +141,7 @@ custom: accepted
if _, ok := decoded.presence[Keys("nested", "a").String()]; ok { if _, ok := decoded.presence[Keys("nested", "a").String()]; ok {
t.Fatal("parent mapping made omitted child explicit") t.Fatal("parent mapping made omitted child explicit")
} }
if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerBase { if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerMain {
t.Fatalf("origins = %#v", decoded.origins) t.Fatalf("origins = %#v", decoded.origins)
} }
if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 { if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 {

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

@@ -3,7 +3,7 @@ package engine
import ( import (
"errors" "errors"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
var ( var (
@@ -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

@@ -6,7 +6,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) { func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
@@ -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

@@ -70,7 +70,7 @@ func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T)
child := config.Catalog.Entries["child"] child := config.Catalog.Entries["child"]
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height) child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
config.Catalog.Entries["child"] = child config.Catalog.Entries["child"] = child
resolution.Record(Keys("catalog", "entries", "child", "derived"), ChangeNormalization, "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height")) resolution.RecordNormalization(Keys("catalog", "entries", "child", "derived"), "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height"))
return nil return nil
}, },
Validate: func(config *configuration, validation *Validation) { Validate: func(config *configuration, validation *Validation) {

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type importDestinationConfig struct { type importDestinationConfig struct {
@@ -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}}}
@@ -287,7 +287,7 @@ func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testi
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
observed.resolve++ observed.resolve++
configuration.Tenant.Derived = configuration.Tenant.Dynamic configuration.Tenant.Derived = configuration.Tenant.Dynamic
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic")) resolution.RecordNormalization(Keys("tenant", "derived"), "derive from mounted input", Keys("tenant", "dynamic"))
return nil return nil
}, },
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) { Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
@@ -425,7 +425,7 @@ func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
definition: Definition[config]{ definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
configuration.Tenant.Derived = configuration.Tenant.Dynamic configuration.Tenant.Derived = configuration.Tenant.Dynamic
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic")) resolution.RecordNormalization(Keys("tenant", "derived"), "derive from dynamic input", Keys("tenant", "dynamic"))
return nil return nil
}, },
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" }, Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },

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

@@ -5,7 +5,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) { func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
@@ -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)
} }

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