365 Commits

Author SHA1 Message Date
453da684d7 Write release notes for v0.10.2 2026-10-26 01:19:38 +00:00
fb6c254d38 Mark import simplification roadmap complete 2026-10-26 00:21:57 +00:00
a71413ce67 Document import and facade boundaries 2026-10-26 00:21:40 +00:00
d66338a1c5 Construct placement directly across the facade 2026-10-26 00:18:12 +00:00
643f43ce6a Snapshot import defaults at load preflight 2026-10-26 00:13:34 +00:00
c25cf39d19 Consolidate engine import defaults 2026-10-26 00:10:07 +00:00
7d18c93339 Extract patterned glob entry preparation 2026-10-26 00:08:23 +00:00
b1d89810ca Plan discovery and facade boundary refactoring 2026-10-26 00:03:02 +00:00
bb5540b62e Plan import discovery and facade simplification 2026-10-25 23:55:45 +00:00
80cf23e710 Mark the implementation roadmap complete 2026-10-25 23:26:29 +00:00
ac48557535 Document and verify the remediation sprint 2026-10-25 23:26:10 +00:00
b4af900c10 Index secret failure locations lazily 2026-10-25 23:21:29 +00:00
f132cd6468 Index inherited compound identity restoration 2026-10-25 23:17:59 +00:00
75e209eb3b Index tier history sources lazily 2026-10-25 23:13:00 +00:00
1dd94a3cc6 Share sequence history construction 2026-10-25 23:09:41 +00:00
d172a5e46a Apply sparse collection policy across overlays 2026-10-25 23:05:23 +00:00
3a1b6ff355 Borrow lower collection policies during strict composition 2026-10-25 22:59:03 +00:00
68383f34de Index collection policy retirement 2026-10-25 22:49:02 +00:00
549fca239d Separate collection overlay responsibilities 2026-10-25 22:42:21 +00:00
883244936c Share layered tier application 2026-10-25 22:37:46 +00:00
a8ae2e9d5e Clarify load context and inheritance handoffs 2026-10-25 22:34:52 +00:00
f18582106b Isolate standalone input completion 2026-10-25 22:32:52 +00:00
d8bf6abc2b Isolate source directive processing 2026-10-25 22:30:46 +00:00
4bf940c579 Use deferred acquisition consistently 2026-10-25 22:28:35 +00:00
4fd3bee986 Strengthen composition and import selection tests 2026-10-25 22:21:51 +00:00
6f6130fd12 Use production directive extraction in import tests 2026-10-25 22:19:38 +00:00
3122e2f32d Remove unread private transport members 2026-10-25 22:18:12 +00:00
e4a5ecb4be Remove disconnected facade clone scaffolding 2026-10-25 22:13:00 +00:00
826062aa17 Test public historical path enumeration 2026-10-25 22:09:55 +00:00
08f2a8f3e9 Cache approved dynamic descriptors per operation 2026-10-25 22:07:37 +00:00
12d350a292 Enforce the retained history node limit 2026-10-25 22:04:54 +00:00
4fcff5f453 Prepare inspection history before publication 2026-10-25 21:53:24 +00:00
247a12bc50 Remove unused combination evidence and helpers 2026-10-25 21:49:52 +00:00
119b42c810 Remove unused history document copies 2026-10-25 21:47:53 +00:00
0f9ecf12ed Share link dependency evaluation with host access 2026-10-25 21:45:33 +00:00
7bf2f0c687 Memoize completed native link dependencies 2026-10-25 21:43:26 +00:00
8aba43da38 Preserve directory traversal failure phases 2026-10-25 21:33:36 +00:00
877c497325 Close owned import roots during panic unwinding 2026-10-25 21:27:47 +00:00
08a8c25542 Close callback contexts when callbacks panic 2026-10-25 21:25:57 +00:00
ea27f17f5c Check projection budgets before container allocation 2026-10-25 21:25:00 +00:00
ae3408a096 Distinguish finite slice views from cycles 2026-10-25 21:22:01 +00:00
deb9aa5c69 Roll back failed dynamic descriptor builds 2026-10-25 21:20:41 +00:00
2736ddfbf2 Terminate pointer-only descriptor traversal 2026-10-25 21:18:35 +00:00
7889ebddfb Plan the audit remediation sprint 2026-10-25 20:52:11 +00:00
4a5316b7fc Describe the audit remediation target and dependencies 2026-10-25 20:25:18 +00:00
e1e94eeec7 Settle audit refactoring scope and defer follow-up work 2026-10-25 20:17:47 +00:00
176e9a5d90 Select localized audit efficiency improvements 2026-10-25 20:13:57 +00:00
23c1976ef1 Settle the initial audit remediation scope 2026-10-25 20:12:12 +00:00
aab79d9508 Consolidate repository audit findings for review 2026-10-25 20:07:46 +00:00
ecd5d5d460 Record dead code and obsolete functionality audit 2026-10-25 17:22:25 +00:00
b1c278b38b Record test coverage and effectiveness audit findings 2026-10-25 17:07:39 +00:00
eb5a82f1f3 Record security audit findings 2026-10-25 16:53:57 +00:00
b30c9f167d Record error handling audit findings 2026-10-25 16:44:08 +00:00
5925066d35 Record resource management audit findings 2026-10-25 16:37:05 +00:00
0cca633a67 Record concurrency and lifetime audit results 2026-10-25 16:25:10 +00:00
a80273433e Record maintainability and complexity audit findings 2026-10-25 16:18:07 +00:00
320d0c5f43 Record algorithmic efficiency audit findings 2026-10-25 16:05:49 +00:00
5098b56655 Record functional correctness audit findings 2026-10-25 15:52:43 +00:00
47944d22d1 Update ReleaseKit to v0.4.2 2026-10-25 15:37:32 +00:00
50199eb0af Retire completed consolidation roadmap documents 2026-10-25 15:09:34 +00:00
34cbc0d63b Document ConfigKit v0.10.1 2026-10-25 15:07:06 +00:00
5e12b2d360 Retire the completed clone traversal review 2026-10-25 14:58:56 +00:00
8dfaf44d1d Index import dependencies for local scope scheduling 2026-10-25 14:41:07 +00:00
64be8b4df5 Keep import selection evidence with its declaring source 2026-10-25 14:36:39 +00:00
e585decdb2 Reuse validated filesystem facts through acquisition 2026-10-25 14:33:17 +00:00
6440a500f7 Record import bookkeeping scalability findings 2026-10-25 14:29:40 +00:00
d898cb193d Record the remaining context consolidation gap 2026-10-25 14:00:29 +00:00
557e006a4a Complete consolidation roadmap validation 2026-10-25 13:51:36 +00:00
3fd83b6b2b Document consumer and internal loading flows 2026-10-25 13:49:44 +00:00
0fbce5d141 Separate schema field discovery and assembly 2026-10-25 13:46:51 +00:00
ac4ce242a2 Separate reflection projection handlers 2026-10-25 13:45:27 +00:00
fa6e876ab5 Extract inheritance accounting and finalization 2026-10-25 13:43:26 +00:00
c5275df1fc Return inheritance combination evidence together 2026-10-25 13:41:41 +00:00
f404168900 Extract inheritance input preparation 2026-10-25 13:32:01 +00:00
729c6fb5d5 Extract imported source admission from traversal 2026-10-25 13:28:43 +00:00
d314a269d5 Separate directory entry admission from traversal 2026-10-25 13:25:37 +00:00
2c3eb3308b Extract import declaration parsing helpers 2026-10-25 13:23:09 +00:00
6bc625b68f Split import acquisition and projection responsibilities 2026-10-25 13:21:29 +00:00
b915dd776a Consolidate selected overlay acquisition 2026-10-25 13:18:54 +00:00
9a64a65b6c Carry resolved filesystem facts through loads 2026-10-25 13:13:47 +00:00
6158e9db3b Separate filesystem context from access 2026-10-25 13:09:44 +00:00
0d149e51f0 Wire load lifecycle through input acquisition 2026-10-25 13:07:16 +00:00
bd51b9c3f2 Consolidate import request ownership 2026-10-25 13:02:46 +00:00
d05426d8a2 Introduce non-generic input acquisition requests 2026-10-25 12:55:04 +00:00
3f0efcb4ff Normalize ordinary and grouped load preflight 2026-10-25 12:52:50 +00:00
19f5c2a2d5 Remove unused reverse source conversions 2026-10-25 12:50:11 +00:00
455a4a355d Map ConfigKit consolidation callers and coverage 2026-10-25 12:37:33 +00:00
8ab1568352 Plan the ConfigKit consolidation sprint 2026-10-25 12:14:01 +00:00
8a99e61070 Defer clone traversal review to future work 2026-10-25 11:54:46 +00:00
de7513d488 Settle retaining the public loading APIs 2026-10-24 23:07:09 +00:00
26771dc03e Consolidate refactoring roadmaps and record open questions 2026-10-24 22:04:26 +00:00
fbaaaf18cd Draft the ConfigKit consolidation roadmap toward 1.0 2026-10-24 21:51:29 +00:00
c9574ef3c1 Record complexity audit findings 2026-10-24 19:54:13 +00:00
5959537d1a Prepare the ConfigKit v0.10.0 release 2026-10-24 19:45:33 +00:00
0369111d30 Resolve Windows rooted search paths with Go 2026-10-24 19:24:30 +00:00
2fedb0307f Use Go rules for Windows filename components 2026-10-24 19:20:54 +00:00
087050fa25 Isolate user configuration discovery tests by platform 2026-10-24 19:18:13 +00:00
1ac1f8bc9e Preserve failing search candidate diagnostics 2026-10-24 19:16:50 +00:00
4dc3e70877 Validate search filesystem context after selection 2026-10-24 19:13:35 +00:00
f64497cb13 Record discovery algorithm audit findings 2026-10-24 19:03:04 +00:00
68cf5c489b Record configuration discovery review findings 2026-10-24 17:52:20 +00:00
e50fee2ca5 Complete the configuration discovery implementation plan 2026-10-24 16:26:09 +00:00
eec214944c Align discovery ownership and contributor documentation 2026-10-24 16:24:57 +00:00
4f2c113237 Extend discovery coverage across consumer workflows 2026-10-24 16:20:53 +00:00
2d93a5c703 Expose configuration search through the public facade 2026-10-24 16:16:07 +00:00
2f6c1eca00 Integrate configuration searches into engine loading 2026-10-24 16:11:03 +00:00
8642337966 Load ordinary overlays without a main source 2026-10-24 15:59:39 +00:00
53ce6c63d8 Allow absent inputs to use import contexts 2026-10-24 15:55:10 +00:00
2f0900535d Select configuration files through host rules 2026-10-24 15:46:36 +00:00
4748313105 Expand ordered configuration search candidates 2026-10-24 15:43:10 +00:00
e2634c8815 Define immutable configuration searches 2026-10-24 15:39:59 +00:00
424c89780e Plan configuration discovery implementation 2026-10-24 15:04:02 +00:00
92309ddcc3 Update the discovery roadmap baseline 2026-10-24 14:54:56 +00:00
7ff204bf3d Clarify v0.9.0 import and collection migration guidance 2026-10-24 14:48:21 +00:00
b3ec860fac Document the ConfigKit v0.9.0 release 2026-10-24 14:44:58 +00:00
435dea102f Retire the completed remediation record 2026-10-24 14:41:56 +00:00
ea46001f78 Reuse document indexes for collection policy checks 2026-10-24 14:17:09 +00:00
a0e5693d07 Share indexed fallback locations during effective projection 2026-10-24 14:13:52 +00:00
5c5f2af6cc Limit inheritance policy work to the relevant scope 2026-10-24 14:10:44 +00:00
8253993005 Delete CLI metadata through the replaced subtree 2026-10-24 14:05:42 +00:00
d0848290da Keep sequence projection storage proportional to assigned items 2026-10-24 14:04:26 +00:00
e94a0642ff Keep global registrations out of item-local composition 2026-10-24 13:59:34 +00:00
571d063675 Retire the completed sequence roadmap and implementation plan 2026-10-24 13:56:19 +00:00
d0a605afbb Document the internal complexity refactor roadmap 2026-10-24 02:02:43 +00:00
7a83704f43 Clarify discovery contexts and filename selection boundaries 2026-10-24 02:00:17 +00:00
ac5bf29ddc Settle discovery slug and custom path rules 2026-10-24 01:58:48 +00:00
cce8356b8e Settle empty discovery searches and filename selections 2026-10-24 01:55:02 +00:00
b746dcc609 Settle discovery behavior when no main file is found 2026-10-24 01:51:08 +00:00
9c32b8d3b5 Record sequence algorithm audit findings 2026-10-24 01:46:55 +00:00
c73daf891d Settle discovery directory resolution errors 2026-10-24 01:39:25 +00:00
ae038a5165 Settle discovery presets and directory helpers 2026-10-24 01:33:53 +00:00
367bbaaa1b Clarify sequence operation history in the configuration contract 2026-10-24 01:21:11 +00:00
fafc28421f Build item policies without repeated accumulated copies 2026-10-24 01:20:12 +00:00
1a673753b9 Preserve child identity in inheritance history 2026-10-24 01:17:00 +00:00
988e49bb09 Define discovery filename groups and search presets 2026-10-24 01:16:42 +00:00
5bb4f364b7 Track the main version through sequence movement 2026-10-24 01:11:28 +00:00
af84107905 Document composable configuration discovery 2026-10-24 01:00:08 +00:00
f94dd9b961 Order CLI metadata history after inheritance 2026-10-24 00:55:21 +00:00
146e7e56d9 Retire obsolete collection identity before replacement agreement 2026-10-24 00:51:10 +00:00
d2e20a304a Record sequence feature integration audit findings 2026-10-24 00:38:04 +00:00
8d40983271 Avoid repeated prefix scans when projecting item policies 2026-10-24 00:19:14 +00:00
25cf7ce05c Clarify final identity validation and overlay semantics 2026-10-24 00:16:53 +00:00
57edb8ec19 Identify conflicting sequence registration positions 2026-10-24 00:15:23 +00:00
42663cf07c Retain main version warnings in layered loads 2026-10-24 00:13:54 +00:00
085b57eeb6 Validate final identity fields as application payload 2026-10-24 00:12:31 +00:00
dcf0379e02 Apply nested collection operations when copying mappings 2026-10-24 00:11:21 +00:00
8ecfedc91b Reset inherited YAML identity on actual replacement 2026-10-24 00:07:22 +00:00
f7624ae92a Project incoming source destinations through tier operations 2026-10-24 00:05:24 +00:00
a035df9845 Preserve tier sequence operations in source history 2026-10-24 00:01:46 +00:00
e6598810e0 Project sequence origins from incoming to final item paths 2026-10-23 23:51:33 +00:00
f8cbb2ed87 Preserve retained input presence in nested CLI overrides 2026-10-23 23:47:21 +00:00
af80ccb90c Record sequence implementation review findings 2026-10-23 23:30:27 +00:00
0be7cdb00e Audit sequence identity scaling and lifecycle 2026-10-23 22:03:16 +00:00
e01aeca761 Reconcile sequence identity documentation 2026-10-23 21:57:35 +00:00
d4133f23f5 Add integrated sequence catalog examples 2026-10-23 21:50:10 +00:00
5af71a7533 Validate final registered sequence identities 2026-10-23 21:43:54 +00:00
41b5dff69b Apply CLI overrides after typed decoding 2026-10-23 21:37:13 +00:00
ecfb78d84d Prepare post-decode CLI inspection context 2026-10-23 21:20:19 +00:00
041c4cff02 Apply CLI overrides to decoded configuration 2026-10-23 21:11:54 +00:00
23f22d8982 Prepare reusable CLI override batches 2026-10-23 21:02:46 +00:00
0023617a07 Expose sequence identity registrations through the facade 2026-10-23 20:59:23 +00:00
33a51606da Resolve inheritance between identified records 2026-10-23 20:48:46 +00:00
268fd03579 Prepare reusable inheritance scope execution 2026-10-23 20:32:10 +00:00
9d4d2e2a17 Build identified record inheritance graphs 2026-10-23 20:23:09 +00:00
36ff9170b7 Apply collection policies during sibling inheritance 2026-10-23 20:19:05 +00:00
9e75496664 Prepare collection policy transport through inheritance 2026-10-23 20:04:06 +00:00
80d8c1dc82 Preserve nested collection policy ownership 2026-10-23 19:56:06 +00:00
acd9e9a9fa Integrate collection policies across layered assembly 2026-10-23 19:49:12 +00:00
89a9684f0d Activate selected overlay collection policies 2026-10-23 19:30:48 +00:00
9a7027a04c Activate ordinary source collection declarations 2026-10-23 19:12:39 +00:00
dfeaf90320 Integrate policy-aware item scopes 2026-10-23 18:55:03 +00:00
d03e56398b Resolve collection identity during strict composition 2026-10-23 18:39:04 +00:00
628fc53d2c Separate underlay acquisition from composition 2026-10-23 18:22:37 +00:00
5fec16a768 Separate input acquisition from composition 2026-10-23 18:15:01 +00:00
f6b02dec03 Project sequence evidence through item mappings 2026-10-23 18:07:15 +00:00
7b04dc6a50 Extend history replay for mapped sequence items 2026-10-23 17:55:45 +00:00
12fdf0d331 Implement shared sequence operations 2026-10-23 17:49:05 +00:00
b202dd187f Track collection policy ownership 2026-10-23 14:16:22 +00:00
0314e587b6 Reconcile collection declarations and modes 2026-10-23 14:07:07 +00:00
9d7635d1e4 Parse collection sequence declarations 2026-10-23 14:01:39 +00:00
298e70b552 Add dynamic sequence identity selectors 2026-10-23 13:55:25 +00:00
64c51647f0 Add shared sequence identity primitives 2026-10-23 13:49:22 +00:00
bc6b977585 Split the sequence implementation into smaller stages 2026-10-23 13:40:29 +00:00
d06aae5461 Plan sequence identity and combination implementation 2026-10-23 12:56:23 +00:00
4f941f8ba4 Clarify sequence policy interactions and rename the roadmap 2026-10-23 12:20:18 +00:00
9a49833339 Remove the completed operator convention audit 2026-10-23 12:17:29 +00:00
749cf6868c Settle the sequence identity registration API 2026-10-23 12:09:44 +00:00
a5f0b92ee9 Add dynamic identity constraints to the sequence roadmap 2026-10-23 01:56:00 +00:00
6ec23b2de1 Extend collection inheritance architecture to identified sequences 2026-10-23 01:42:32 +00:00
3298eb916c Settle how sequence inheritance references compose 2026-10-23 01:40:37 +00:00
40430e787c Add identified record inheritance to the sequence roadmap 2026-10-23 01:31:22 +00:00
f434fa159f Align architecture policy with collection and CLI decisions 2026-10-23 01:12:11 +00:00
8ddf73ede3 Accept collection policy and CLI lifecycle decisions 2026-10-23 01:09:24 +00:00
844a15a3cb Consolidate the sequence roadmap around the settled design 2026-10-23 00:11:50 +00:00
58f4c39d6f Settle sequence identity declarations without values 2026-10-23 00:04:55 +00:00
8a54cecd1d Settle nested sequence policy ownership and record review findings 2026-10-23 00:02:26 +00:00
0e430e8077 Allow lower-tier identity during strict composition 2026-10-22 23:53:48 +00:00
26caa6024e Settle persistent sequence operation policy 2026-10-22 23:47:50 +00:00
8691a93ebf Settle CLI ordering before application resolution 2026-10-22 23:41:06 +00:00
c563f80934 Record remaining sequence lifecycle decisions 2026-10-22 15:45:21 +00:00
27797d2392 Settle sequence inheritance and lifecycle coverage 2026-10-22 15:16:42 +00:00
6a4e57e5d3 Settle sequence validation and absent value behavior 2026-10-22 14:47:00 +00:00
29f94d89e1 Settle sequence mode names and identity-based defaults 2026-10-22 14:39:40 +00:00
12f6045da0 Settle sequence combination scope and ordering 2026-10-22 14:08:55 +00:00
6eb80b06e3 Settle sequence identity definitions and policy lifetime 2026-10-22 13:28:18 +00:00
86feb6d755 Settle collection declaration syntax in the sequence roadmap 2026-10-21 14:00:02 +00:00
8bf49f6ea6 Include overlay author controls in the sequence proposal 2026-10-20 14:08:57 +00:00
f3d0fe6411 Record final identity guarantees for application registrations 2026-10-20 14:03:26 +00:00
cba3a0efb0 Record whole-record sequence replacement and application identity constraints 2026-10-20 13:57:19 +00:00
b58cd75c98 Draft sequence identity and layered append proposal 2026-10-20 13:48:54 +00:00
c530a989df Audit operator conventions against Ansible 2026-10-20 13:28:21 +00:00
2baf14d9a5 Retire completed import integration remediation 2026-10-20 04:11:54 +00:00
6903c9e51a Clarify empty path import inspection evidence 2026-10-20 01:06:15 +00:00
31e10af9cb Clarify scheduled provenance and history ordering 2026-10-20 01:05:34 +00:00
0d841d9d73 Resolve contained symlinks in import directory prefixes 2026-10-20 01:04:31 +00:00
4bcefbc9d4 Record import integration audit findings 2026-10-20 00:38:04 +00:00
91db99b967 Retire completed import discovery plans 2026-10-20 00:32:11 +00:00
5747ec49c6 Count import depth only for selected sources 2026-10-20 00:30:35 +00:00
d3a714a1cb Validate complete import depth numbers 2026-10-20 00:27:55 +00:00
7f8ad81065 Track active symlink expansions precisely 2026-10-20 00:26:30 +00:00
75da54150f Resolve native imports beneath nested roots 2026-10-20 00:23:55 +00:00
38fb315415 Check item version ownership at effective paths 2026-10-20 00:22:43 +00:00
1c79ec3aa1 Preserve native import alias identity 2026-10-20 00:20:38 +00:00
01e1b2fc2d Record import discovery review findings 2026-10-20 00:03:23 +00:00
15943e233c Record completed import roadmap 2026-10-19 21:23:54 +00:00
f6a6b0184b Finalize import discovery documentation and audits 2026-10-19 21:23:47 +00:00
898035d83a Record prompt catalog example completion 2026-10-19 21:15:53 +00:00
de3666aed3 Expand prompt catalog discovery example 2026-10-19 21:15:47 +00:00
c00811da2e Record lifecycle audit completion 2026-10-19 21:13:29 +00:00
55abcf8340 Audit scheduled import and inheritance lifecycles 2026-10-19 21:13:21 +00:00
9d7c79cf0b Record public import API completion 2026-10-19 21:06:39 +00:00
24131617c7 Expose import defaults and selection evidence 2026-10-19 21:06:31 +00:00
05b67cbfcf Record empty import evidence completion 2026-10-19 20:57:53 +00:00
e433988962 Retain empty optional import selection evidence 2026-10-19 20:57:41 +00:00
05a28d1178 Record final import wiring completion 2026-10-19 20:51:01 +00:00
1e5bffc0e6 Wire final import declarations through loading 2026-10-19 20:50:47 +00:00
f5a689b578 Record glob discovery completion 2026-10-19 20:38:48 +00:00
e4abff4164 Filter and account final glob candidates 2026-10-19 20:38:42 +00:00
1a8f1cc3a5 Record directory link discovery completion 2026-10-19 20:29:36 +00:00
9b99463187 Follow links safely during directory discovery 2026-10-19 20:29:20 +00:00
f69fa24b40 Record recursive directory search completion 2026-10-19 20:18:44 +00:00
77d97296ea Add recursive directory search with depth limits 2026-10-19 20:18:39 +00:00
032679bf34 Record shallow directory selection completion 2026-10-19 20:13:39 +00:00
55a59de6db Select shallow directory import sources 2026-10-19 20:13:33 +00:00
a0d13962bd Record named import selection completion 2026-10-19 20:08:05 +00:00
5f33e1e90c Select named imports before source admission 2026-10-19 20:07:54 +00:00
b750b0fec6 Record private import parser completion 2026-10-19 20:02:12 +00:00
b498e5bee5 Define private import defaults and declaration parsing 2026-10-19 20:02:05 +00:00
ba577429e6 Record underlay group scheduling completion 2026-10-19 19:53:08 +00:00
014b99504b Schedule underlay groups as dependency forests 2026-10-19 19:53:01 +00:00
263dd2f1bd Record item scope scheduling completion 2026-10-19 19:39:30 +00:00
af43d6f438 Schedule item-local import scopes 2026-10-19 19:39:19 +00:00
a4cbd6390e Record ordinary import scheduling completion 2026-10-19 19:29:52 +00:00
5ed0f55970 Schedule ordinary import contributions by dependency 2026-10-19 19:29:35 +00:00
91ab37a39d Record import graph completion 2026-10-19 17:34:45 +00:00
fbe9ae6d8b Retain import inclusion graph during acquisition 2026-10-19 17:34:21 +00:00
1aa121df71 Record scheduled inheritance completion 2026-10-19 17:25:32 +00:00
6e40c166d6 Schedule sibling inheritance by dependency readiness 2026-10-19 17:25:27 +00:00
a56d4691ff Record inheritance graph groundwork 2026-10-19 15:18:37 +00:00
2718fbb8fe Separate inheritance graph analysis and application 2026-10-19 15:18:25 +00:00
2d52421919 Record deterministic scheduler completion 2026-10-19 15:11:42 +00:00
167fcd475f Add deterministic dependency scheduler 2026-10-19 15:11:28 +00:00
aa93aeda3f Plan import discovery and shared dependency ordering 2026-10-19 14:33:01 +00:00
94381a67fd Record accepted import architecture decisions 2026-10-19 14:24:19 +00:00
c26b34147b Clarify import candidate identity, accounting, and inspection 2026-10-19 14:15:01 +00:00
c9f9c98fbe Settle import validation and require recursion for depth 2026-10-19 14:05:18 +00:00
f6bf4454b0 Settle import selector lists and exclusions 2026-10-19 13:43:56 +00:00
a7ca2c59a2 Settle import defaults, discovery boundaries, and inspection decisions 2026-10-19 01:54:01 +00:00
10c14aa794 Add architecture principles for simplicity and permissive defaults 2026-10-18 21:13:28 +00:00
e9774b252b Adopt shared dependency-ready ordering for imports and inheritance 2026-10-18 21:09:05 +00:00
19fe621df1 Apply permissive defaults and remove import transition support 2026-10-18 16:37:07 +00:00
82b289f4fe Clarify import roadmap decisions using Ansible conventions 2026-10-18 16:28:38 +00:00
137769f3f1 Document the ConfigKit v0.8.0 release 2026-10-17 04:58:01 +00:00
ef28eabaf8 Retire completed sequence composition plans 2026-10-17 04:56:51 +00:00
008d2ba9c8 Clarify when sequence identities are validated 2026-10-17 04:55:32 +00:00
8e3a0892d4 Project item conflict diagnostics through enclosing scopes 2026-10-17 04:54:01 +00:00
023e428b9c Index sequence assembly and history lookups 2026-10-17 04:52:20 +00:00
527a38daf1 Preserve defaults-only input absence 2026-10-17 04:49:33 +00:00
30bcd8eeb6 Protect the main version inside item scopes 2026-10-17 04:48:25 +00:00
884215c403 Honor parsed sequence indexes in version protections 2026-10-17 04:46:53 +00:00
8a89806e44 Explain descendants removed by sequence replacement 2026-10-17 04:45:49 +00:00
26720b98f0 Preserve item helper history in source order 2026-10-17 04:44:29 +00:00
4633a1b8a2 Project consumed version evidence through sequence appends 2026-10-17 04:42:23 +00:00
87986a12d2 Record sequence composition review findings 2026-10-17 01:50:02 +00:00
0858af1029 Report malformed sequence items at assigned paths 2026-10-16 19:41:48 +00:00
acddce8e2f Document sequence composition for consumers 2026-10-16 19:37:59 +00:00
77c5d9482a Close cross-cutting sequence regression gaps 2026-10-16 19:32:31 +00:00
adef16ccdd Expose sequence placement controls in the public API 2026-10-16 19:23:43 +00:00
c19825620c Project version protections through item placement 2026-10-16 19:11:08 +00:00
190431740a Apply sequence policy across loading tiers 2026-10-16 18:59:52 +00:00
25cf6d32b6 Assemble imported sequence-item scopes 2026-10-16 16:20:14 +00:00
1a04bb3835 Add private sequence-item placement 2026-10-16 15:55:29 +00:00
1cf3b0d78f Retain ordered acquisition scopes 2026-10-16 15:48:36 +00:00
66dced0823 Project history and sensitivity paths 2026-10-16 15:44:44 +00:00
b1b5163cc3 Preserve append history across tiers 2026-10-16 15:36:46 +00:00
bb9bde799a Project sequence origins onto appended indexes 2026-10-16 15:29:27 +00:00
dc7a70811a Compose registered sequences by identity 2026-10-16 15:25:06 +00:00
ff212885c4 Prepare sequence registration preflight 2026-10-16 15:19:32 +00:00
90c2d7e409 Clarify unresolved source destination evidence in implementation plan 2026-10-16 14:49:23 +00:00
60e6e2697f Plan sequence placement and composition 2026-10-16 14:36:46 +00:00
20511773fc Settle sequence placement and composition roadmap decisions 2026-10-16 14:22:59 +00:00
31fb6a9bd1 Extend sequence roadmap with item placement and open decisions 2026-10-16 14:18:36 +00:00
103e10639a Plan identity-based sequence composition 2026-10-16 14:05:43 +00:00
97c5a0390a Retire deferred scope and preserve future performance experiments 2026-10-16 13:53:34 +00:00
8b547765f0 Record selective source version requirements as future work 2026-10-16 13:42:37 +00:00
5215cf5c4f Catalog identified record sequences as future work 2026-10-16 12:07:49 +00:00
e66c37161c Retire completed roadmaps before the next release 2026-10-16 11:36:21 +00:00
e4270c9b14 Allow absent version headers below structural controls 2026-10-15 22:22:56 +00:00
e69ad0f32d Keep complete paths in missing version evidence 2026-10-15 22:20:20 +00:00
0a2246bdbe Check import opt-in before processing source headers 2026-10-15 22:18:57 +00:00
e5a6df7af7 Preserve header sensitivity across colliding path shapes 2026-10-15 22:17:49 +00:00
25cb3c47e7 Record source version review findings 2026-10-15 22:11:44 +00:00
020d72084d Document shared source version checks 2026-10-15 16:23:39 +00:00
358b28c421 Add source version adoption example 2026-10-15 16:19:24 +00:00
b0550af6a3 Audit source version lifecycle agreement 2026-10-15 16:17:21 +00:00
43c994a32a Publish detached sanitized source evidence 2026-10-15 16:12:21 +00:00
1e47ee21cd Propagate source evidence sensitivity through history 2026-10-15 16:00:01 +00:00
fed591183b Classify source version evidence at original paths 2026-10-15 15:55:16 +00:00
1f95cf905a Expose source version modes in load options 2026-10-15 15:48:59 +00:00
15f5b591b9 Check source versions in selected overlays 2026-10-15 15:44:04 +00:00
184f29c7af Check source versions in direct underlays 2026-10-15 15:40:11 +00:00
813d6a38c9 Validate source versions in recursive imports 2026-10-15 15:33:40 +00:00
23ae700194 Integrate source version checks for main inputs 2026-10-15 15:24:53 +00:00
49218a836b Add source-local version extraction 2026-10-15 15:15:05 +00:00
2fcf892324 Separate version interpretation from reporting 2026-10-15 15:04:57 +00:00
8645cab79a Record shared source version policy 2026-10-15 15:02:50 +00:00
70c95bf660 Plan shared source version checks 2026-10-15 14:24:51 +00:00
d9f08ff719 Settle source version edge-case decisions 2026-10-15 14:05:25 +00:00
2c91e41d10 Record source version decisions and remaining edge cases 2026-10-14 20:30:58 +00:00
e3860036f8 Plan shared source version checks 2026-10-14 19:03:25 +00:00
b3fc6a5950 Restore the original input destination decision 2026-10-14 18:05:48 +00:00
a858a066e4 Record relative import destination review findings 2026-10-14 17:52:14 +00:00
10edd95d4d Complete relative import destination documentation audit 2026-10-14 17:45:31 +00:00
08e03f6a38 Demonstrate entry-declared import destinations 2026-10-14 17:41:35 +00:00
a20ba0af27 Verify relative imports through the application lifecycle 2026-10-14 17:39:28 +00:00
6458dbd3aa Preserve import provenance and resource contexts 2026-10-14 17:34:52 +00:00
43e55dfa85 Verify accumulated import resource accounting 2026-10-14 17:29:54 +00:00
430985c629 Verify relative import composition and version ownership 2026-10-14 17:27:49 +00:00
4635ef08d5 Add relative destinations to import declarations 2026-10-14 17:24:57 +00:00
49c0c6d908 Pass effective destinations through import traversal 2026-10-14 17:21:07 +00:00
a8beb3e620 Record relative import destination decision and baseline 2026-10-14 17:17:50 +00:00
934c1022e0 Record PromptKit feature request decisions 2026-10-14 17:09:23 +00:00
7248a8bd95 Plan relative import destination implementation 2026-10-14 16:53:50 +00:00
b2db3233b2 Settle relative import destination syntax 2026-10-14 16:44:11 +00:00
edb6d05ec1 Plan relative import destinations 2026-10-14 16:39:10 +00:00
315f464dfc Correct mounted mapping diagnostic paths 2026-10-14 15:49:51 +00:00
a029a2a754 Record input destination review findings 2026-10-14 14:12:18 +00:00
945e111c4e Complete input destination implementation audit 2026-10-14 14:06:38 +00:00
2d8df22dd1 Add mounted collection consumer examples 2026-10-14 13:59:37 +00:00
53601e68ed Expose input destinations through the public facade 2026-10-14 13:56:22 +00:00
751787ec24 Verify mounted inheritance and sensitivity integration 2026-10-14 13:50:08 +00:00
f50122d9f8 Verify mounted provenance and resource contexts 2026-10-14 13:38:56 +00:00
c71faf2065 Count destination wrappers in shared input limits 2026-10-14 13:31:32 +00:00
2f5d57ce1d Verify mounted diagnostic paths and locations 2026-10-14 13:25:55 +00:00
a1c557603f Verify mounted composition and version ownership 2026-10-14 13:24:06 +00:00
a2fab66aba Carry destinations through imported configuration 2026-10-14 13:19:31 +00:00
2f1f7f4b8a Integrate private destinations for layered inputs 2026-10-14 13:14:06 +00:00
667a536ae7 Add private input destination placement helpers 2026-10-14 13:09:06 +00:00
ea55ab43ce Record input destination decision and baseline 2026-10-14 13:05:09 +00:00
d3d468a8b4 Plan configuration input destination implementation 2026-10-14 12:48:08 +00:00
6353598bd6 Settle input destination roadmap decisions 2026-10-14 12:40:06 +00:00
3c2d7facb3 Plan configuration input destinations 2026-10-14 03:27:34 +00:00
6fffde4af8 Prepare ConfigKit v0.7.1 release 2026-10-11 01:14:23 +00:00
9e04a397c4 Upgrade ReleaseKit to v0.4.1 2026-10-11 01:10:26 +00:00
c54a9bf51e Upgrade ReleaseKit to v0.4.0 2026-10-10 23:51:44 +00:00
257 changed files with 40212 additions and 2400 deletions

View File

@@ -9,7 +9,7 @@ ConfigKit's simple `Load` accepts one strict YAML source (or explicit
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
source groups with an independently selected main input. Sources can be host
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
Enabled path and glob 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,
validated configuration with detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
@@ -17,7 +17,8 @@ then report domain errors and warnings through read-only semantic validation. Se
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply above overlays. Value explanation is available through
CLI override records apply after inheritance and typed decoding, before application
resolution. Value explanation is available through
detached structured records and deterministic redacted output. Built-in
`extends` relationships let a mapping inherit from a named sibling during the
same load, retaining explicit presence and source/derivation history. The root
@@ -26,6 +27,29 @@ the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
Sources can also be explicitly placed as sequence items. Configuration
declarations and application registrations can give selected sequences scalar
identities and choose how they combine across precedence and inheritance.
Strict peers combine distinct records, while higher-priority collections can
replace, append, or upsert whole records. See the
[sequence contract](docs/config.md#strict-document-composition),
[maintained catalog examples](docs/consumers/README.md#identified-prompt-sequences),
and [ADR 0017](docs/adr/0017-collection-sequence-policy.md).
Imports are also explicit: a source may name files, search declared directories,
or select files with path globs. Applications can set load-wide import defaults
while keeping declaration-specific exceptions. Files are acquired in authored
declaration order; dependency-ready composition uses a shared stable scheduler.
ConfigKit does not search conventional application paths implicitly. See the
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
[ADR 0016](docs/adr/0016-independent-import-controls.md).
Main-file discovery is explicit too: applications choose and order `Search`
descriptions and pass `SearchConfig` to the existing load APIs. See the
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
```go
type Config struct {
Enabled bool `yaml:"enabled"`
@@ -60,20 +84,29 @@ mappings combine, while overlapping non-mapping definitions are errors. Between
precedence tiers, mappings combine recursively, retaining omitted lower-priority
children; other values replace lower-priority values. The main document and its
imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay and explicit CLI overrides sit
above it. The [configuration contract](docs/config.md#strict-document-composition)
owns the detailed combination rules and input-specific constraints.
and above typed defaults; a selected overlay applies before inheritance, while
explicit CLI overrides apply after decoding and before application resolution.
The [configuration contract](docs/config.md#strict-document-composition) owns
the detailed combination rules and input-specific constraints. Declared or
registered collections combine distinct identified records within strict tiers
and use their selected operation at precedence boundaries; other sequences keep
their strict-conflict and whole-value-replacement behavior.
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
these priorities and how genuine new needs can justify extending the shared
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
consideration in future releases.
The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained simple
native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes.
See the [development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.
The [configuration contract](docs/config.md) describes implemented behavior,
including [input destinations](docs/config.md#input-destinations). The
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
layered, mounted-collection, and identified-prompt examples alongside the
migration shapes. Input placement decisions are recorded in
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
See the [development guide](docs/development.md) for repository orientation and
the [release procedure](docs/release.md) for maintainer-only candidate and
publication steps.
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

@@ -43,10 +43,61 @@ func toEngineLocations(values []Location) []engine.Location {
return result
}
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
return SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
}
}
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
if values == nil {
return nil
}
result := make([]ImportSelection, len(values))
for index, value := range values {
result[index] = ImportSelection{
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
}
}
return result
}
func toEngineOverrides(values []Override) []engine.Override {
if values == nil {
return nil
}
result := make([]engine.Override, len(values))
for index, value := range values {
result[index] = engine.Override{
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
}
}
return result
}
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
return engine.ImportDefaults{
Required: value.Required,
Follow: value.Follow,
Hidden: value.Hidden,
Recursive: value.Recursive,
Pattern: value.Pattern,
}
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil {
return nil
}
return &SourceVersionCheck{
Path: fromEnginePath(value.Path),
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
}
func fromEngineOrigin(value engine.Origin) Origin {
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
@@ -61,19 +112,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
}
return result
}
func toEngineOrigin(value Origin) engine.Origin {
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
if value.Inheritance != nil {
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
for index, step := range value.Inheritance {
result.Inheritance[index] = engine.InheritanceStep{
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
Locations: toEngineLocations(step.Locations),
}
}
}
return result
}
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
}

90
discovery.go Normal file
View File

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

View File

@@ -1,6 +1,8 @@
# ADR 0005: Record explicit CLI overrides and safe value explanation
Status: accepted
Status: Partially superseded by
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
ordering. The remaining ownership and safe explanation decisions remain accepted.
Date: 2026-09-06

View File

@@ -2,7 +2,9 @@
## Status
Proposed draft for review. Completed planning and remediation records are
Proposed draft for review; its whole-sequence precedence replacement rule is
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
retains its proposed status. Completed planning and remediation records are
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
[configuration contract](../config.md#layered-loading) owns current behavior.

View File

@@ -2,8 +2,14 @@
## Status
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
owns current behavior. The completed implementation plan is preserved in Git
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
sequence combination and the addition of identified-sequence sibling scopes,
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
ordering. Existing mapping-sibling scopes and the remaining ownership and
inspection decisions remain accepted.
The [configuration contract](../config.md#sibling-inheritance) owns implemented
behavior. The completed implementation plan is preserved in Git
history at `83c59ea`.
## Date

View File

@@ -0,0 +1,74 @@
# ADR 0011: Place Configuration Inputs at Mapping Destinations
## Status
Accepted and implemented. Current placement behavior is owned by the
[configuration contract](../config.md#input-destinations). The accepted
relative-import extension is recorded in
[ADR 0012](0012-relative-import-destinations.md). The completed roadmap, consumer
request, and review records are preserved at `e4270c9` under `docs/roadmap/` in
Git history; the original input-destination implementation plan remains at
`315f464:docs/roadmap/implementation.md`.
## Date
2026-10-14
## Context
Consumers assembling collections of independently authored configuration
fragments need each source to contribute beneath a selected application path.
Requiring consumers to rewrite source documents or merge completed values would
separate placement from ConfigKit's existing composition, precedence, source
identity, and inspection behavior. Placement should remain part of the ordinary
loading lifecycle and preserve the root-owned public facade.
## Decision
Add an optional `Destination Path` to root-owned `Input` and expose the effective
destination on existing `SourceInfo` records. The empty path means the existing
application root. A non-empty destination is a sequence of literal mapping keys;
explicit sequence-index segments are invalid. Existing reserved structural keys
remain reserved, while keys that merely contain their spelling remain ordinary
keys.
Acquire and interpret each source using its original physical identity and
source-local directives, then place its application body before strict
composition and the existing final lifecycle. Imported application bodies
inherit the input destination. Placement applies to main and underlay inputs;
ordinary root loading, selected overlays, and CLI inputs retain root placement.
The main-owned version rule continues to check its effective application path
at its existing lifecycle point.
Placement preserves authored node locations and source identity. Introduced
mapping structure belongs to the contributing source and uses that document's
root location as its source-coordinate fallback. A present empty mapping
materializes its destination; an absent `NoSource` contributes nothing. The
result continues through the existing strict composition, overlay, inheritance,
typed loading, diagnostics, provenance, history, sensitivity, and resource
context mechanisms.
The added exported fields preserve keyed and zero-value struct use. Callers
using unkeyed composite literals for `Input` or `SourceInfo` must migrate to
keyed literals. Treat this feature as minor-release work; select the version and
document the compatibility impact during separately authorized release
preparation, consistent with the release policy.
## Alternatives considered
- **Rewrite or merge sources in consumers.** This makes each consumer reproduce
placement, strict composition, source attribution, and inspection semantics.
- **Add a separate loading entry point or public wrapper type.** This creates a
parallel API for a behavior that belongs to existing per-input loading.
- **Load each fragment as a complete application before placement.** This runs
defaults, decoding, resolution, and validation on incomplete fragments rather
than once on the assembled application.
## Consequences
Consumers can assemble contributions at effective mapping paths in one ordinary
layered load and inspect each source's placement through existing source
records. Paths, conflicts, and later application operations use the placed
structure while source coordinates and relative-resource contexts continue to
identify their original sources. Public field additions require release-note
guidance for callers using unkeyed literals.

View File

@@ -0,0 +1,78 @@
# ADR 0012: Place Imported Configuration at Relative Destinations
## Status
Accepted and implemented. The
[configuration contract](../config.md#input-destinations) owns current behavior.
The completed roadmap and review records are preserved at `e4270c9` under
`docs/roadmap/` in Git history; the relative-import implementation plan remains
at `d9f08ff:docs/roadmap/implementation.md`.
## Date
2026-10-14
## Context
ConfigKit can place an input document at a literal mapping path while retaining
source identity, composition, and inspection in its normal loading lifecycle.
An importing document can currently bring in other documents, but those
contributions inherit only the importing input's destination. Consumers that
assemble a collection therefore cannot declare the layout of imported
fragments alongside the import itself without rewriting source documents or
performing another merge outside ConfigKit.
The extension should reuse input placement and the existing import lifecycle.
Logical application placement must remain independent from physical path
resolution, source identity, precedence, and resource ownership.
## Decision
Add an optional `destination` field to each YAML import declaration. Its value
is a sequence of strings representing literal mapping-key segments. Omission
and an empty sequence add no segments. A non-empty destination appends to the
effective destination of the immediately importing document; nested imports
continue from the resulting child destination. Every match of one glob
declaration receives the same computed destination.
Use the existing input-destination validation and reserved-key rules. Do not
interpret destination segments as filesystem paths, separators, sequence
indices, or root-reset instructions. Physical import paths continue to resolve
from their declaring source under existing containment rules.
Imports remain in their existing strict-composition group. A destination does
not grant precedence, bypass cycle or repeated-source rules, or change source
identity. Existing placement, version ownership, source records, diagnostics,
resource contexts, inheritance, history, and inspection mechanisms continue to
govern the placed contributions. No public Go entry point or independent
specification-header behavior is introduced.
This is additive YAML functionality and is minor-release work. Version
selection, release notes, roadmap retirement, and publication remain separate
release-preparation work.
## Alternatives Considered
- **Require complete application-root destinations on every import.** This
avoids inherited context but repeats collection prefixes in nested source
declarations and does not express placement relative to the importing
document.
- **Encode destinations as dotted or slash-delimited strings.** This introduces
separator and escaping rules even though existing paths already represent
literal mapping keys as segments.
- **Rewrite or merge imported documents in consumers.** This duplicates source
placement, strict composition, attribution, and inspection behavior outside
ConfigKit's loading lifecycle.
- **Add independent specification headers or collection-specific validation.**
These are separate design questions and are not required to declare relative
placement.
## Consequences
Import declarations can describe the logical placement of their contributions
while preserving the physical source and existing composition lifecycle.
Placement is immediate-parent-relative, so nested imports accumulate their
declared segments and sibling declarations remain independent. The feature is
implemented behind the existing public loading surface; detailed validation,
diagnostic, accounting, and inspection requirements are tracked by the
implementation roadmap.

View File

@@ -0,0 +1,92 @@
# ADR 0013: Check Source Versions with One Shared Policy
## Status
Accepted and implemented. The
[configuration contract](../config.md#shared-source-version-checks) owns current
behavior. The completed roadmap, implementation plan, consumer request, and
resolved review findings are preserved at `e4270c9` under `docs/roadmap/` in
Git history; the [development guide](../development.md#baseline-validation)
provides retrieval examples.
## Date
2026-10-15
## Context
Applications that assemble one configuration from independently authored
documents need a way to require that each contributing document declares a
version accepted by the application's existing `Definition.Version` checker.
Checking only the eventual composed value lets another source hide a rejected
declaration, while separate consumer-side parsing duplicates ConfigKit's source
handling and diagnostics.
The extension must preserve one application-owned acceptance policy and the
existing loading lifecycle. Source declarations are compatibility evidence, not
independent fragment schemas or values to compare for equality. Existing
composition, placement, precedence, source identity, diagnostics, and
sensitivity rules remain authoritative.
## Decision
Provide a load-wide source-version mode for ordinary and layered loading. The
zero-value mode preserves current behavior. The enabled modes check declarations
when present or require them, using the existing `Definition.Version` path and
checker. Interpret the configured path relative to each participating source
before placement or composition. Different declarations may be accepted by the
same checker; ConfigKit adds no equality, SemVer, or expected-version rule.
Apply the selected policy to the actual main document, direct underlays,
imports, and selected overlays. Missing non-main declarations are skipped in
if-present mode and rejected in required mode. Existing main absence behavior
remains, with required mode additionally rejecting an absent declaration on an
actual main document. `NoSource`, unmatched optional inputs, synthetic field
operations, and empty groups do not become documents that need declarations.
Reuse the existing version vocabulary and validation boundary: absence, null,
exact strings, and strict decimal integers retain their established meanings;
unsupported present values fail version-type validation. Warnings remain
nonfatal, and source declarations do not replace final application decoding or
domain validation.
After an accepted non-main declaration is checked, consume exactly that node
before its remaining application content is composed or overlaid. Preserve the
main declaration and its value semantics. Keep the declaration, source-local
path, physical location, and checked absence as detached source evidence,
separate from effective application values and history. Apply established
sensitivity rules to that evidence, including original mounted paths and
ancestors; retain existing safe failure disclosure.
In enabled modes, the main declaration remains source-local while its protected
application path is derived from the main input's destination. This distinction
does not change default-mode path interpretation or weaken protection of
remaining payload and synthetic operations.
Public option-field additions and the enabled-mode interpretation for mounted
main inputs are compatibility considerations for separately authorized minor-
release preparation. This decision does not select a release version or
authorize release notes or publication.
## Alternatives Considered
- **Check only the final composed value.** Later sources can hide an earlier
rejected declaration, so this does not validate every contributing document.
- **Let consumers parse and check each source.** This duplicates acquisition,
source attribution, safe diagnostics, and lifecycle coordination in every
application.
- **Require declarations to be equal or impose a universal version grammar.**
Consumers already own the acceptance policy; ConfigKit need not invent an
expected value or a domain-specific compatibility rule.
- **Keep accepted non-main declarations in application payload.** A selected
source-local declaration is metadata for this policy and cannot also remain
an ordinary field at the same path without ambiguity.
## Consequences
Consumers can apply one existing version policy across contributing documents
and select gradual or required adoption per load. Accepted non-main headers no
longer participate in application composition; their inspectable evidence is
kept separate and subject to sensitivity classification. Default callers retain
current behavior. The implementation plan records the concrete API names,
integration order, and validation evidence.

View File

@@ -0,0 +1,81 @@
# ADR 0014: Place and Compose Sequence Items Explicitly
## Status
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
identity ownership and sequence combination. Explicit source placement remains
accepted.
The [configuration contract](../config.md#strict-document-composition)
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
shows the maintained example. The completed roadmap, implementation plan, and
resolved remediation record are preserved in Git history at `008d2ba` under
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
commands.
## Date
2026-10-16
## Context
ConfigKit strictly composes independent documents by recursively combining
mappings and rejecting other overlapping values. This makes two independently
authored sequence fragments conflict, even when their records are distinct.
Precedence assembly has a different purpose: a higher-priority present sequence
replaces the lower-priority sequence as one value. Those existing rules are
predictable, but they do not express the separate operation of collecting
independent records while rejecting duplicate identities.
Likewise, mapping destinations place a source beneath literal mapping keys, but
there is no explicit way to state that a source mapping represents one item in a
sequence. Inferring that intent from document shape or fields would make source
meaning implicit and change existing placement behavior.
## Decision
Keep mapping placement as the zero-value behavior and add an explicit item
placement choice for a source whose mapping should become one element at a
non-root sequence destination. Item placement provides structure only; it does
not infer or synthesize identity values.
Allow a definition to register literal mapping paths and one literal identity
field for sequences at those paths. Within each strict composition tier, a
registered sequence accepts mapping records with a present string identity,
appends distinct records in contribution order, and rejects repeated exact
identity strings. Identical registrations coalesce; conflicting identity fields
for the same path are invalid. Registration does not recursively select paths
inside sequence records.
Keep precedence assembly unchanged. A present higher-priority sequence replaces
the lower-priority value wholesale, including at a registered path. Registration
changes strict composition only; it does not turn precedence into append or
identity-based merging.
This decision preserves ConfigKit's distinction between independent
same-priority contributions and intentional precedence. Explicit source
placement and definition-owned composition policy make the two required choices
visible while keeping the existing zero-value load behavior.
## Alternatives Considered
- **Infer item placement from a source mapping or identity field.** Shape-based
inference would make ordinary mapping placement depend on document contents
and would leave the intended destination ambiguous.
- **Append all overlapping sequences during strict composition.** This would
change existing conflict semantics at unregistered paths and would not detect
duplicate records.
- **Merge matching records recursively by identity.** This would add a second
recursive merge rule with different conflict and precedence behavior from
ConfigKit's existing mapping composition and overlay operations.
- **Use identity to merge sequences across precedence tiers.** This would
change established whole-value replacement semantics and conflate strict
composition with precedence.
## Consequences
Consumers can explicitly place mappings as sequence items and opt selected
sequences into duplicate-rejecting strict composition. Unregistered sequences
retain their current strict-conflict behavior, and higher-priority sequences
continue to replace lower-priority ones. The implementation plan owns the exact
API, YAML syntax, diagnostics, and implementation status.

View File

@@ -0,0 +1,68 @@
# ADR 0015: Share Dependency-Ready Ordering Across Imports and Inheritance
## Status
Accepted. Implemented behavior is described in the
[configuration contract](../config.md#explicit-imports) and
[internal import overview](../internal/imports.md).
## Date
2026-10-19
## Context
Imports and sibling inheritance both resolve relationships between configuration
contributions. Their existing resolvers use different traversal rules, making
ordering depend on which mechanism expresses a dependency. Ordering affects
sequence contributions, provenance, and diagnostics, so a consistent rule is
part of ConfigKit's configuration model.
The resolvers also serve distinct purposes. Imports strictly compose sources;
inheritance overlays a child on its resolved parent after source assembly.
Item-local contributions and nested inheritance introduce scope boundaries that
resolution must preserve.
## Decision
ConfigKit owns one shared dependency-ready scheduler for imports and sibling
inheritance. Both resolvers use Kahn's algorithm with a stable ordering key,
selecting the smallest currently eligible node after its dependencies have
completed. The shared scheduler owns eligibility, deterministic tie-breaking,
and dependency-cycle identification. Each resolver owns construction of its
dependency relationships and application of its domain operations.
Apply the common scheduling rule within the scopes where dependencies are
available. Complete item-local contributions before item placement and resolve
enclosing inheritance before relationships in the resulting nested scopes.
Source discovery supplies the import graph; the scheduler determines resolution
order.
Strict composition, inheritance overlay, and precedence tiers retain their
existing ownership and meanings under [ADR 0001](0001-separate-composition-and-overlay.md).
The lifecycle and scope decisions in
[ADR 0009](0009-sibling-inheritance.md) continue to govern inheritance. Import
admission retains its cycle and repeated-source rules.
## Alternatives Considered
- **Deterministic depth-first postorder.** Keeps dependency branches together,
but gives traversal branch selection an additional role in ordering.
- **Dependency rounds.** Groups nodes by readiness rounds, but delays newly
eligible nodes until the current round finishes.
- **Descending longest-path depth.** Prioritizes the furthest dependencies,
but adds distance from the root as an ordering policy.
- **Separate resolver algorithms.** Allows local specialization, but leaves
consumers and maintainers with different rules for analogous dependencies.
## Consequences
Both resolvers follow one deterministic ordering model and share responsibility
for maintaining that model. Resolver-specific scopes, combination semantics,
and source-admission rules remain explicit at their integration boundaries.
Independent branches may interleave as nodes become eligible. Adopting this
rule changes existing traversal-dependent sequence positions and reporting
order, requiring coordinated review of source assembly, provenance, and
inheritance behavior. The configuration contract owns the implemented ordering
details.

View File

@@ -0,0 +1,83 @@
# ADR 0016: Separate Import Selection, Traversal, and Presence Requirements
## Status
Accepted. Implemented behavior is described in the
[configuration contract](../config.md#explicit-imports), the
[public API declarations](../../lifecycle.go), and the
[internal import overview](../internal/imports.md).
## Date
2026-10-19
## Context
Consumers assemble configurations from named files and collections whose layout
varies between installations. Simple imports should require little setup, while
larger collections need control over discovery, recursion, filtering, and
required contributions. Combining those choices implicitly makes declarations
harder to interpret and pushes source-selection work into applications.
Discovery can also reach a candidate through overlapping searches or encounter
different names for one canonical source. Selection and source admission need
distinct identities to support useful search unions while preserving ConfigKit's
existing source-integrity rules.
## Decision
ConfigKit owns import discovery and selection within its source-loading
lifecycle. Support explicit files, directory searches, and path globs as
distinct selection forms. Keep traversal, filename filtering, and presence
requirements independently expressible. Discovery determines the selected
contributions; the shared scheduler in
[ADR 0015](0015-shared-dependency-ready-ordering.md) determines their resolution
order.
Use optional-by-default imports with explicit requiredness controls. Selected
sources remain subject to ConfigKit's validity, containment, resource, and
composition contracts. This applies the library's
[simplicity and defaults principles](../policy/architecture.md#simplicity-defaults-and-explicit-controls)
to import absence while retaining meaningful errors for invalid contributions.
Applications own their selected import defaults. Authored declaration values
take precedence over application defaults, which take precedence over library
defaults. Each declaration resolves applicable controls independently,
including declarations in imported sources. This gives applications a shared
baseline while preserving explicit choices in individual declarations.
Keep declaration-local candidate identity separate from canonical source
identity. Search unions coalesce repeated candidate paths within a source
namespace before symlink resolution; filename filtering uses the selected or
discovered name. Source admission continues to use canonical identity and the
existing repeated-source rules. Distinct aliases retain their separate
inclusion routes.
## Alternatives Considered
- **Use path globs for all discovery.** Offers one selection form, but makes
collection traversal depend on path-pattern structure and complicates
independently controlling recursion and filename filtering.
- **Offer only explicit files and directory searches.** Reduces the selector
vocabulary, but moves wildcard directory selection into repeated declarations
or application-side discovery.
- **Require every import by default.** Detects absent contributions immediately,
but requires repeated opt-outs for installations with optional components.
- **Make application settings override authored declarations.** Provides central
enforcement, but prevents declarations from expressing local exceptions.
- **Deduplicate selections globally by canonical source identity.** Coalesces
aliases and separate inclusion routes, but changes the existing repeated-source
contract and obscures independently authored contributions.
## Consequences
Consumers can express simple imports and more selective collection searches
through the same loading lifecycle. Applications choose defaults and validate
their required effective configuration; ConfigKit owns consistent selection,
source admission, composition, and inspection.
Optional absence can conceal misspelled selectors, so requiredness and
inspection provide explicit ways to detect and explain missing contributions.
Declaration-local deduplication requires the loader to preserve candidate
identity alongside canonical source identity. The configuration and public API
contracts own the implemented behavior.

View File

@@ -0,0 +1,143 @@
# ADR 0017: Define Identity and Merge Rules for Each Collection
## Status
Accepted.
This supersedes the decisions about who declares identity and how
sequences combine in [ADR 0014](0014-sequence-placement-and-composition.md),
and the rule that inheritance always replaces sequences in
[ADR 0009](0009-sibling-inheritance.md). It also extends that ADR's sibling
scopes to identified sequences. Existing mapping-sibling scopes and source
placement continue to apply. It supersedes the whole-sequence
precedence replacement rule in the proposed
[ADR 0007](0007-source-backed-underlays.md).
## Date
2026-10-22
## Context
ConfigKit can load separate files as records in a sequence. Applications can
register an identity key so records with different identities combine and
duplicates fail. However, a higher configuration layer or an inheriting mapping
currently replaces the whole sequence. Applications that need to extend a
catalog across layers must write their own merging code and keep source and
history information consistent with its results.
Configuration authors need to choose how records are identified and whether a
layer adds records, updates records, or replaces the collection. Applications
also need a way to require unique identities in the final loaded configuration.
These rules must work consistently for imported records, inline sequences,
overlays, and inheritance.
Records in a sequence may also share settings. Authors need to reuse those
settings through inheritance while keeping each record's own identity.
## Decision
ConfigKit manages identity and merge rules for each collection during loading.
Configuration files can declare those rules for imported and inline sequences,
including sequences in selected overlays. Applications can require a particular
identity definition, and configuration declarations must agree with it. Source
placement determines where each contribution enters the configuration.
Keep the distinction between contributions at the same priority and overrides
from a higher layer, as established in
[ADR 0001](0001-separate-composition-and-overlay.md). Within one tier, records
with distinct identities combine; duplicate identities and conflicting
declarations fail. Determine the applicable rules before combining those
contributions, including any identity established in lower tiers.
Across layers, support three operations: replace the whole sequence, append
records while rejecting duplicate identities, or replace whole records by
identity (`upsert`). Source layering and sibling inheritance use the same
operations. For inheritance, the rules at the inheriting destination determine
which operation applies.
An identified sequence is also a sibling scope for inheritance. A record may
inherit from one other record in the same sequence, selected by its complete
identity. Each record supplies its own identity before inheritance. The child
keeps that identity and combines its settings with the parent's other fields,
using the existing mapping and collection rules. Parents remain ordinary records
subject to normal validation.
Resolve these relationships after source assembly, using the same dependency
ordering as mapping-sibling inheritance. Resolve nested relationships at each
inheriting destination. Preserve the assembled sequence order. Whole-record
upsert keeps its existing meaning; inheritance then uses the records that remain.
Typed defaults, CLI overrides, and application resolution follow the lifecycle
in [ADR 0018](0018-cli-after-structural-resolution.md).
An explicitly selected operation remains in effect at its destination until a
higher layer changes it. When no explicit operation applies, use upsert if an
identity definition is available; otherwise replace the whole sequence. Authors
can declare identity and an operation before supplying any records. Keep source
acquisition order separate from layer priority and the dependency ordering
established by [ADR 0015](0015-shared-dependency-ready-ordering.md).
Rules declared inside a record belong to that record. Retaining the record
retains its local rules; replacing it discards them. Replacing a whole collection
can discard its previous YAML identity definition, while application requirements
still apply. Inheritance can copy collection identity, but the inheriting
destination chooses its operation. Application registrations continue to govern
the paths where they were registered. The same ownership rules apply to
declarations made before a sequence exists.
Check YAML-declared identity when combining collections during source assembly
and inheritance. Application registrations also require the final loaded
sequences to satisfy their identity rules, including after typed defaults, CLI
overrides, and application resolution. This guarantee checks sequences that
exist; the application schema and validation decide whether a collection must
exist and which value types are allowed. The guarantee applies when loading
completes, rather than to every intermediate callback state or changes made by
the caller afterward.
Preserve explicit presence, source attribution, history, sensitivity, and
resource context through these operations, following
[ADR 0003](0003-preserve-presence-and-resolution-history.md). Typed defaults,
CLI assignments, and application resolution keep their own rules for changing
values. The public API provides registration and inspection; the implementation
chooses how to store and track collection rules internally.
## Alternatives Considered
- **Keep identity application-owned and always replace sequences across layers.**
Keeps the current design small, but reusable catalogs require application
changes or custom merging code.
- **Put merge rules on each import.** Keeps the rules beside the imported source,
but inline sequences and overlays need a separate way to declare them. It also
makes rules for a collection spread across several imports harder to reconcile.
- **Merge individual fields of records with matching identities.** Allows partial
record updates, but adds rules for field conflicts and retained values.
Replacing the whole record makes the update's scope clear.
- **Keep inheritance limited to mapping siblings.** Keeps parent lookup simpler,
but authors of sequence records must duplicate shared settings or applications
must implement inheritance themselves. Collection identity provides a way to
select a parent within the existing loading process.
- **Check every YAML identity again at the end of loading.** Gives YAML declarations
authority over values changed by defaults, CLI assignments, and application
resolution. This extends the lifetime of those declarations; application
registration provides that final guarantee when needed.
## Consequences
Consumers can build and customize catalogs in one load while retaining useful
inspection information. Configuration authors choose how collections combine
within application requirements. Duplicate identities among contributions at
the same priority remain errors.
Existing registered collections use upsert across layers by default unless an
explicit operation selects otherwise. Correct loading now requires tracking
which declarations still apply as records are replaced, copied, or moved to new
positions. Lower-tier identity must be available before combining higher-tier
contributions.
Record inheritance also requires parent lookup by identity and checking for
missing parents and cycles. Copied settings retain the same source, history,
and sensitivity information as mapping-sibling inheritance.
The [configuration contract](../config.md#strict-document-composition) defines
declaration syntax, identity comparison, omission and empty-value behavior, and
diagnostics for the implemented feature.

View File

@@ -0,0 +1,84 @@
# ADR 0018: Apply CLI Overrides After Inheritance and Decoding
## Status
Accepted.
This supersedes the rule that CLI overrides apply before decoding in
[ADR 0005](0005-explicit-overrides-and-safe-explanation.md) and the
rule that they apply before inheritance in [ADR 0009](0009-sibling-inheritance.md).
Their remaining ownership, inheritance scope, and safe inspection decisions
continue to apply.
## Date
2026-10-22
## Context
Explicit command-line values are the operator's highest-priority input. If they
apply before inheritance, a sequence supplied as a replacement could gain
inherited records afterward. Its final contents would then differ from the
operator's assignment.
The application also needs these values when it fills missing settings,
normalizes values, or computes derived settings. ConfigKit needs a clear loading
order that preserves both CLI precedence and the application's role in resolving
the final configuration.
## Decision
ConfigKit uses this loading order:
1. Combine sources within tiers and apply precedence between layers.
2. Resolve sibling inheritance, apply typed defaults, and decode the configuration
into the application's types.
3. Apply explicit CLI overrides and check them against the application schema.
4. Run application resolution using the overridden configuration.
5. Look up effective secret references, validate the final configuration, and
return it.
CLI overrides are the highest-priority input to application resolution. A CLI
sequence replaces the whole sequence; mappings combine recursively. Collection
merge rules apply during the earlier source and inheritance steps. Inheritance
and typed defaulting do not run again after CLI overrides.
Keep track of which values were explicitly supplied and which CLI input supplied
them. Applications continue to parse command-line arguments; ConfigKit applies
and validates the overrides and makes their effects inspectable. Reject
structural directives in override paths and values, so instructions such as
inheritance relationships remain part of source processing.
Application resolution keeps its existing rules for defaults and recorded
changes under [ADR 0003](0003-preserve-presence-and-resolution-history.md).
It receives CLI values together with their presence and source information.
Defaults that only fill missing settings preserve explicit CLI values.
Application normalization and derived-value calculations can still change them,
with those changes recorded. Secret lookup and final validation use the resulting
configuration.
## Alternatives Considered
- **Apply CLI overrides before inheritance.** Preserves existing ordering, but
later merging needs special handling to preserve CLI replacement intent while
still combining ordinary source contributions.
- **Apply CLI overrides after inheritance but before typed decoding.** Allows an
override to fix a source value that would otherwise fail decoding, but leaves
typed defaulting after the operator's assignment. Completing defaulting and
decoding first gives CLI overrides an already resolved configuration to modify.
- **Apply CLI overrides after application resolution.** Applies assignments after
the application's changes, but derived settings still reflect earlier values
unless resolution runs again.
## Consequences
CLI overrides change a configuration whose inheritance and typed defaults are
already resolved. The application can then derive settings from the operator's
values. The final values, source information, history, and sensitivity handling
must remain consistent throughout loading.
CLI input can no longer add or cancel inheritance relationships or bypass an
earlier error in source assembly or inheritance. It also cannot fix a value that
fails the earlier decoding step. Semantic validation still runs after overrides
and application resolution. The [configuration contract](../config.md#explicit-cli-overrides)
and [consumer guidance](../consumers/README.md) describe this behavior.

View File

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

View File

@@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract |
| --- | --- |
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
| `SearchConfig(application, search, options...)` | Creates an explicit, reusable search source. Loading selects one host file or follows the absent-input lifecycle when an optional search is exhausted. |
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
@@ -28,6 +29,79 @@ Keys are sorted for stable provenance, integer decimal spelling stays exact, and
cycles or unsupported types are rejected without exposing values. Mapping input
uses the same directive and version rules as YAML, not CLI override restrictions.
### Configuration discovery
Discovery is opt-in. Construct `Search` from literal `Paths`, directory searches,
or the supported directory presets, then pass it to `SearchConfig`. Search
descriptions are immutable and reusable; each load resolves dynamic directories
and checks current candidate files again. `Searches` is ordered concatenation.
It preserves the supplied description order and does not deduplicate candidates.
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
`main.yaml`. `SearchDirectory` defaults to `[Named, Generic]` when options are
omitted or `Filenames` is nil. A non-nil empty filename list contributes no
candidates and does not resolve its directory. `CustomFilenames` supplies
literal single filename components; it does not expand application-name
placeholders. An application slug is required only when a selected directory or
filename group uses it. Literal custom paths are not expanded for `~`, variables,
or wildcards; relative paths are resolved from the working directory at load time.
The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group
contribute no candidates. An explicitly supplied empty path or filename is
invalid. Slugs and custom filenames must be usable single host filename
components; ConfigKit preserves their case and spelling.
Search expansion is ordered concatenation. For each directory search, ConfigKit
tries its filename groups and names before advancing to the next directory.
When multiple directories use the same filename list, candidates follow the
directory-first Cartesian product: every filename in one directory is tried
before the next directory. Explicit file candidates stay at their declared
positions among directory searches.
The presets expand to these ordered directories and filename groups:
| Preset | Directory, in order | Filename groups |
| --- | --- | --- |
| `UnixSystemDirectories` | `/usr/local/etc/<application>`, `/etc/<application>` | Named, then Generic |
| `UnixSystemDirectories` | `/etc` | Named only |
| `UserConfigDirectory` | Go user-config directory / `<application>` | Named, then Generic |
| `UserConfigDirectory` | Go user-config directory | Named only |
| `HomeDirectory` | Go home directory / `.<application>` | Named, then Generic |
| `HomeDirectory` | Go home directory | Named only |
| `WorkingDirectory` | Current working directory | Named only |
The Unix system preset is supported on aix, android, darwin, dragonfly, freebsd,
hurd, illumos, ios, linux, netbsd, openbsd, and solaris; reaching it on another
platform is an option error. Other presets use Go's user-config, home, and
working-directory helpers. A reached
directory-helper error terminates selection. Bases for later searches are not
resolved after an earlier candidate is selected. Missing candidates continue;
the first existing candidate is selected and then follows ordinary file loading
rules. Errors while acquiring or processing a selected file are terminal and do
not resume the search.
Searches are optional by default. Exhaustion uses the same absent-main lifecycle
as `NoSource()`: there is no main document, source record, source ID, or explicit
presence contribution. Imports may be enabled but there are no main declarations
to traverse. Defaults, version checks, overlays, CLI overrides, resolution, and
validation retain their normal lifecycle. `SearchOptions{Required: true}` makes
exhaustion an error; it does not change candidate or selected-file error handling.
For an ordinary active overlay after optional exhaustion, provide an explicit
filesystem context. A host context with only `Root` is sufficient; ConfigKit
loads the overlay at its normal root-relative location and does not infer a root
from attempted search directories. The same root-only context can accompany a
search that selects a file: the selected file determines its base and must be
inside `Root`. A selected host file rejects an explicit `BaseDir` or native
filesystem context. Invalid context fails without resuming selection. Layered
overlays keep their independent `OverlayFilesystem` requirement.
When a search selects a file, inspection reports that actual file with ordinary
source IDs, paths, placement, metadata origins, and resource context. Missing
candidates create no records. `SearchConfig` can be used as the main source of
ordinary or layered loading, including an underlay input, and does not add a new
precedence tier.
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
@@ -52,10 +126,11 @@ preserves already validated secret state without rereading the environment.
The current lifecycle is:
```text
validate options → parse the selected source → traverse/compose enabled imports
validate options → parse the selected source → extract source controls and
traverse/strictly compose enabled imports
→ build schema → check the optional application version → apply a selected overlay
→ apply explicit CLI overrides → expand sibling inheritance
→ create and clone defaults → strictly decode
→ expand sibling inheritance
→ create and clone defaults → strictly decode → apply explicit CLI overrides
→ recorded application resolution
→ resolve typed secret references → semantic validation → classify secrets
→ snapshot configuration and metadata
@@ -67,13 +142,24 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
semantic validator, explicit path and glob imports, one selected overlay, and
filesystem context. It also accepts explicit CLI override records, static secret
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
and filesystem context as invalid combinations but accepts overrides. Input
containing a reserved `configkit` mapping key is rejected while imports are
disabled.
`Options` accepts the application definition, ordinary-source `Destination` and
`PlacementMode`, explicit imports, one selected overlay, filesystem
context, explicit CLI overrides, and shared source-version controls. The definition
may register sequence identities through `Definition.SequenceIdentities`. Its
zero-valued placement mode places the source mapping
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
source-root collection declarations whether or not imports are enabled; an
`imports` declaration still requires imports to be enabled. `NoSource` may enable
imports, but it has no main declarations to traverse. It accepts an explicit
filesystem context: a host context requires an absolute existing `Root` and may
omit `BaseDir` to use that root as its internal import base; a native context
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
when imports are disabled. An active ordinary overlay requires this explicit
context. `NoSource` accepts overrides and remains absent rather than contributing
an empty document.
Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
@@ -123,62 +209,148 @@ nothing. Underlay groups are listed from lowest to highest priority. Repeating
a file within one group remains an import/repetition error; the same reusable
source may be selected again in another tier.
ConfigKit acquires and composes the main input first, constructs the schema,
and checks the optional application version from the main tier only. Underlay
roots cannot claim that version path (`underlay.version`); imported fragments
retain `import.version` protection. It then acquires and composes underlay
groups in order, recursively combines the groups, and applies the main tier,
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
definitions replace lower values between tiers; mappings combine recursively.
Syntax and strict-composition errors inside a tier cannot be masked by a
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
validation, and publication run once on the final assembled input.
ConfigKit acquires the main input first and constructs the schema. It then
acquires and strictly composes underlay groups from lowest to highest priority,
passing each group's accumulated lower collection policy into the next group.
Each group is applied once as a precedence tier. The main tier is then composed
with the accumulated underlay policy, and the optional application version is
checked against that composed main tier only. Underlay roots cannot claim that
version path (`underlay.version`); imported fragments retain `import.version`
protection. ConfigKit then applies the selected overlay and resolves inheritance.
Scalar, sequence, and null definitions replace lower values between tiers unless
a collection policy defines the sequence operation; mappings combine recursively.
After successful defaults and typed decoding, CLI overrides are applied to that
typed configuration before application resolution. Syntax, strict-composition,
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
secret lookup, validation, and publication run once on the resulting values.
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
independent of the main source's namespace. Its name and root-relative lookup
follow the selected-overlay rules below; inactive overlays ignore the context.
The same path in a different filesystem namespace is not a repeated file.
Ordinary `Load` continues to infer its selected-overlay context from its source.
Ordinary `Load` infers overlay context from sources that provide one; with
`NoSource`, it requires the explicit `Options.Filesystem` context.
Source IDs follow acquisition order—main and its depth-first imports, then each
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
history follows precedence order. Source `Layer` describes precedence while
history follows precedence order. Default main-version checking happens after
underlay composition because the main tier inherits the accumulated collection
policy. Enabled per-source version checks retain acquisition order and source
local headers. Source `Layer` describes precedence while
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
one-based `UnderlayIndex` giving their original tier position; all other records
have index zero. Explicit empty groups and `NoSource` consume no source ID.
Byte, source, node, and glob-entry budgets are shared across the load, while
the existing per-source and import-depth bounds still apply.
When a source mapping is explicitly placed as an item, its effective destination
is the sequence container. After assembly assigns an index, nested source
destinations are projected through that item. Physical source locations and
resource contexts continue to identify the declaring source.
#### Input destinations
`Input.Destination` places the source's application mapping beneath a path
relative to the configuration root. Its zero or empty value means root, so
existing inputs keep their behavior. Main inputs and underlay inputs use the
same destination model. Destination segments are literal mapping keys: dots,
slashes, and numeric-looking text are not split or interpreted as sequence
indexes. Explicit index segments and paths that introduce the reserved
`configkit` or mapping-valued `extends` structure are invalid input options and
produce a structured `LoadError` before source acquisition.
Source-root `configkit.imports` directives are processed before placement. An
import without an explicit destination inherits the effective destination of
its importing document, including through nested imports. An import may instead
declare a relative destination as a sequence of literal mapping keys; its keys
append to the importing document's effective destination. Omission and `[]`
add no keys. Nested imports continue relative to their immediate importing
document, each declaration is independent of its siblings, and all matches of
one glob receive the same resulting destination. This logical placement does
not change how the physical import path resolves from its declaring file.
A valid destination on `NoSource()` contributes nothing; an actual empty
mapping materializes its destination containers. Once placed, contributions
follow the same strict composition within a tier and recursive precedence
behavior described above. A selected overlay and CLI overrides remain rooted at
the application root and are not moved beneath the main input's destination.
`SourceInfo.Destination` reports each acquired source's effective mapping or
sequence-container path, including imports. `PlacementMode` reports how that
source was placed; mapping is the zero value, and CLI/overlay sources report
mapping unless item placement was applied. `DestinationUnresolved` is true only
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
collection container and omits the unresolved local suffix. A source's own item
placement still has an exact destination at its sequence container. Completed
loads project nested source destinations through their owning items. Callback and
failed-load snapshots preserve the evidence available at that point; a fallback
snapshot remains unresolved, while a later exact snapshot is false. Returned
source records and paths are detached. Placement does not change source IDs,
acquisition identity, physical source locations, or passive resource contexts.
Introduced ancestor mappings use the contributing document's root location as a
source fallback; authored content retains its original source coordinates.
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
and explicit resource access without a second configuration pass.
The accepted rationale for input placement and its relative-import extension
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
[ADR 0012](adr/0012-relative-import-destinations.md).
### Sibling inheritance
Any input mapping may declare `extends: name` to inherit from the mapping named
`name` among its immediate siblings. Names are exact keys, including the empty
string; lookup does not search ancestor or unrelated scopes. The document root
and a sequence-element mapping have no sibling namespace for their own string
relationship, though mappings nested inside them can have one. An `extends`
string; lookup does not search ancestor or unrelated scopes. A mapping inside a
sequence can also participate in record inheritance when that sequence has an
effective identity declaration from `configkit.sequences` or an application
registration. In that case, `extends` is a mapping containing exactly the
sequence's identity fields, with scalar values; lookup is limited to that final
sequence. The document root and a sequence-element mapping without an effective
identity declaration have no sibling namespace for non-null string
relationships, though mappings nested inside them can have one. An `extends`
value of null cancels a relationship without looking up a parent. Other value
types, missing or non-mapping parents, and cycles fail the load with value-free
diagnostics.
types, incomplete or extra reference fields, missing parents, and cycles fail
the load with value-free diagnostics.
Within each mapping sibling scope or identified sequence, direct definitions
resolve by dependency readiness. A parent is resolved before a child; among
currently eligible definitions, the lexically smallest canonical semantic
pointer is selected, including newly eligible definitions as soon as their
parent completes. This ordering governs inheritance operations and their
history, controls, and transfers. It does not reorder mappings or sequence
records in the resulting configuration.
`extends` is reserved structural input across ordinary and layered loads, every
source kind, imports, selected overlays, and explicit CLI overrides. It is
consumed before typed decoding and need not appear in the application's Go
types. Applications previously using `extends` as an ordinary YAML field must
rename that key and its field. No collection registration or keyword setup is
required.
required for mapping-sibling relationships; identified-sequence relationships
require effective identity fields for that collection.
ConfigKit completes strict composition within tiers and precedence assembly
between tiers before resolving relationships. An omitted `extends` retains a
lower-priority relationship; `extends: null` cancels it while preserving the
child's own settings. Superseded references are historical evidence, not active
relationships to validate. Each child recursively overlays its own settings
over its resolved parent: explicit zero, false, empty string, sequence, and
null values replace inherited values, while mappings combine recursively.
The child retains its key. All effective relationships resolve, including
between tiers before resolving relationships. Only records that survive that
assembly participate in identified-sequence lookup: an upserted parent supplies
its final record, a replaced parent is unavailable, and a higher replacement
without `extends` supersedes a lower relationship. An omitted `extends` retains
a lower-priority mapping-sibling relationship; an identified record's effective
directive depends on the record that survives assembly. `extends: null` cancels
a relationship while preserving the child's own settings. Superseded references
are historical evidence, not active relationships to validate. Each child
recursively overlays its own settings over its resolved parent: explicit zero,
false, empty string, and
null values replace inherited values, while mappings combine recursively. For
identified records, the child's identity fields and their origins remain its
own; parent identity fields do not contribute to the payload.
Sequences at collection destinations use the shared collection operation with
the parent as lower input and the child as higher input. The destination's
explicit mode applies; otherwise a copied identity selects upsert and an
unkeyed destination replaces. A parent's explicit mode does not move to a
different destination. Sequences outside collection destinations remain atomic
replacements.
All effective relationships resolve, including
entries the application may never select for execution. Partial parent
definitions undergo normal typed decoding and application-owned validation,
not a special structural exemption.
@@ -269,6 +441,67 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures.
#### Shared source-version checks
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
one `Definition.Version` policy applies across source documents. The zero value
`SourceVersionModeDefault` preserves the existing main-only check at its
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
checks each participating document that declares a version and permits absent
non-main declarations. `SourceVersionModeRequired` rejects a participating
document without a declaration. Enabled modes require a usable `VersionRule`
and checker; an unknown mode or unusable rule is an options error before source
access or callbacks.
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
document's own coordinates, before placement and composition. The main document,
direct underlay documents, imported documents (including transitive and glob
matches), and the selected overlay participate. `NoSource`, absent optional
imports, empty underlay groups, defaults, resolution output, inheritance, and
synthetic CLI overrides are not documents to check. An absent declaration in an
actual main document still reaches the checker in if-present mode; required mode
rejects it first. With `NoSource`, the existing single absent-main checker
behavior remains in both enabled modes. If-present omissions in non-main
documents skip the checker. Explicit null is present and reaches the checker;
unsupported selected values produce `version.type`. No equality with the main
declaration or non-null rule is imposed. Acceptance establishes only that the
declaration is accepted by the consumer's checker; it does not establish that
each fragment conforms to a complete application schema. Final assembled
decoding and application validation still run once.
A header that is absent below a structural control follows the same omission
policy. Actual header/control overlap is rejected, and ordinary structural
validation and main-version ownership protection continue independently.
Accepted non-main declarations are consumed from their owned payload before
composition; their authored ancestors remain, even if empty. The main
declaration remains in the application. Ordinary release metadata should use a
different path. Enabled main protection uses the main input's effective path:
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
the remaining payload against imports, underlays, inheritance, and CLI
overrides. Thus a mounted main looks up a local header while ownership checks
protect its destination-prefixed application path. Main checker diagnostics and
declaration locations use source-local coordinates; ordinary payload
diagnostics continue to use effective paths. Default-mode diagnostics are
unchanged.
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
occur. Otherwise it records a source-local `Path`, the observed `Version` state
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
available `Location`, and whether text was redacted. A non-nil record with
`Present == false` records observed absence; it does not say the checker ran or
accepted a declaration. Absent evidence still identifies the full requested
header path, while its location identifies the nearest available source node.
During callbacks and in failed-load source records,
text is conservatively withheld because final classification may not be
available. Successful final source records reflect completed sensitivity
classification at the declaration's mounted path and ancestors, including
historical classification. Redaction empties `Text` while retaining presence
and null state. Returned copies are detached; evidence does not reopen inputs.
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
decision and the [development guide](development.md#baseline-validation) for
the completed implementation and remediation records in Git history.
### Layered metadata
`Result.Metadata` describes every final canonical path. Queries retain explicit
@@ -299,15 +532,22 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived
value for absent input remains non-explicit.
Strict composition assigns contributed records their assembled sequence positions
in history. Across tiers, history follows the selected
[collection operation](#strict-document-composition), including append and
whole-record upsert. Incoming contributions use their assigned positions, and
replaced payload remains available as historical evidence.
A mapping assembled from the selected file and imports has one composite base origin
whose locations retain source traversal order. A mapping also visited by an overlay
has an overlay-layer composite origin with the lower contributing locations followed
by the overlay location; an empty mapping is still an explicit container even though
it contributes no children. Resolution origins are synthetic: they have layer
whose locations follow [scoped assembly order](#explicit-imports), not source-record
acquisition order. A mapping also visited by an overlay has an overlay-layer
composite origin with the lower contributing locations followed by the overlay
location; an empty mapping is still an explicit container even though it contributes
no children. Resolution origins are synthetic: they have layer
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
operation. Reasons and dependency paths retain first occurrence order with duplicates
removed. Source records remain ordered as selected source, depth-first imports, the
selected overlay, then synthetic CLI records in supplied order.
removed. Source records and IDs retain [acquisition order](#explicit-imports),
including the underlay ordering described in [layered loading](#layered-loading).
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
lists concrete transfers from contributor paths through destinations, with
@@ -370,29 +610,34 @@ historical path. Its effective value and each chronological before/after value a
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
parser values. `Present=false` with empty YAML means absence or deletion; explicit
null is present and renders as `null`. The history starts with the canonical typed
application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected overlay, supplied CLI records, and audited net
resolution operations. Structural inheritance events appear after source replay
and before typed reconciliation, including consumed or canceled `extends` input
and equal-valued child replacements. Overlapping parent/child resolution records
application-default or Go-zero baseline, followed by relevant underlay and main-tier
contributions in precedence order, the selected overlay, and structural inheritance
events. Typed defaulting and decoding reconcile that source history before supplied
CLI records and audited net resolution operations. Within each strict tier, replay follows
[scoped assembly order](#explicit-imports), not source-record acquisition order.
Inheritance events include consumed or canceled `extends` input and equal-valued
child replacements. Overlapping parent/child resolution records
are rendered
as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay
differs from the final typed input, a `ChangeDecoding` event records the transition
before domain resolution. Its origin retains the decoded path's source/default
attribution; its reason identifies static-default retention and canonical decoding.
This built-in inspection operation is not accepted by `Resolution.Record`.
writes remain visible. When structural replay differs from the pre-CLI typed input,
a `ChangeDecoding` event records retained defaults and canonical decoding. CLI
contributions then retain their raw input representation while effective and
resolution values use the typed projection. This built-in inspection operation is
not accepted by `Resolution.Record`.
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.
Baseline, effective, and resolution values use canonical typed projection. A
decoding event bridges replayed input and canonical typed values, so intermediate
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
queries reconstruct each source or override contribution, while sequences remain
atomic: a descendant history identifies its presence before and after the containing
sequence replacement without implying element-wise merging. Removed paths remain
explainable; unknown and malformed paths return false.
queries reconstruct each source or override contribution. A sequence snapshot
represents the collection at that point in replay, and sequence diffs remain
atomic. Descendant queries describe their presence and values before and after
containing sequence operations. Combination follows the
[collection operation tables](#strict-document-composition); upsert replaces
matching records in full. Removed paths remain explainable; unknown and malformed
paths return false.
Every snapshot applies the same historical tag, predicate, and recorded-dependency
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
@@ -479,13 +724,15 @@ must resolve to a nonempty value. Secret references work in typed structs, maps,
pointers, slices, and arrays. A dynamic `any` value does not establish secret
semantics and cannot contain `Secret`; applications must use a typed slot.
ConfigKit resolves only the effective names after recorded application resolution
and before semantic validation. Each unique name is read with `os.LookupEnv` once
per load. Validation can inspect the credential through the deliberately named
`Value()` accessor and distinguish success with `IsResolved()`. Later loads read
the environment again, and cloned defaults are rebuilt from names so a previously
resolved result cannot retain a stale credential. `Load` never writes to the
environment.
ConfigKit resolves only the effective names after CLI assignment and recorded
application resolution, and before semantic validation. CLI overrides may supply
or replace a typed secret reference; lookup uses that effective name and retains
its effective source location for diagnostics. Each unique name is read with
`os.LookupEnv` once per load. Validation can inspect the credential through the
deliberately named `Value()` accessor and distinguish success with `IsResolved()`.
Later loads read the environment again, and cloned defaults are rebuilt from names
so a previously resolved result cannot retain a stale credential. `Load` never
writes to the environment.
Credential bytes are held only in the per-load lookup state and the trusted returned
configuration, and are returned only by `Value`. Secret formatting uses a safe
@@ -549,18 +796,19 @@ files when appropriate; ConfigKit stores no implicit global report.
### Recorded application resolution
An optional `Definition.Resolve` callback runs after successful typed decoding. It
receives the working configuration and a per-load `Resolution` context. The context's
An optional `Definition.Resolve` callback runs after successful defaults, typed
decoding, and explicit CLI assignment. It receives the working configuration and a
per-load `Resolution` context. The context's
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults.
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
decoded entry state, before application resolution runs. It includes retained static
defaults, preserves explicit null, and uses the same exact semantic path matching as
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
found entry path. Resolution changes, additions, removals, and moves do not alter
this entry view.
post-decode entry state, including explicit CLI overrides, before application
resolution runs. It includes retained static defaults, preserves explicit null, and
uses the same exact semantic path matching as `Metadata.Lookup`; missing,
malformed, and key/index-shape-mismatched paths return false without diagnostics.
Its `Explicit` value agrees with `IsExplicit` for every found entry path. Resolution
changes, additions, removals, and moves do not alter this entry view.
`Resolution.Source(id)` returns a detached record for an already acquired source in
the same load: the main source, imports, selected overlay, and supplied CLI records
@@ -635,8 +883,11 @@ live services as a substitute for configuration validation.
## Strict document composition
ConfigKit strictly composes parsed base documents when `Options.Imports` is true.
Application defaults do not participate in this operation.
Ordinary `Load` applies strict document composition to its parsed source scope;
`Options.Imports` adds imported documents as peers in that scope. Application
defaults do not participate. With imports disabled, the single source still has
its controls extracted and any declared or registered collection identity is
validated, but there are no imported peers to combine.
| Existing node | Incoming node | Result |
| --- | --- | --- |
@@ -646,20 +897,148 @@ Application defaults do not participate in this operation.
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
sequences are present atomic values, so they also conflict when repeated; sequences
are never joined by index. Disjoint entries under shared mappings compose, making
stable keyed mappings the natural representation for independently contributed
entries.
Every non-mapping overlap is a conflict, even when values are equal, except for
sequence/sequence contributions at a destination with effective identity from an
application registration, a current YAML `key` declaration, or retained lower
policy. Nulls remain atomic conflicts.
Sequences without effective identity conflict when repeated and are never joined
by index. Disjoint entries under shared mappings compose, making stable keyed
mappings the natural representation for independently contributed entries.
`Definition.SequenceIdentities` contains registrations created by
`SequenceIdentityAt` or `SequenceIdentityMatching`. The exact form accepts a
mapping-only `Path`; the matching form accepts literal-key and any-mapping-key
segments created by `SequenceLiteralKey` and `SequenceAnyMappingKey`. A literal
`"*"` key is distinct from the wildcard constructor. Parsed numeric path tokens
remain literal mapping keys; explicit index segments and reserved `configkit` or
mapping-valued `extends` segments are invalid. The root selector is harmless under
the mapping-root schema. Selector segments consume one mapping level and match a
complete destination; an any-mapping-key segment matches exactly one key, not an
arbitrary prefix or suffix. Each registration requires one or more literal,
direct field names; compound field sets are normalized as sets, and field names
are not otherwise normalized. The same field-set and scalar-equality rules apply
to YAML `key` declarations.
At each selected path, records must provide every effective identity field as a
scalar string, number, boolean, or explicit null. Field names form a set, so
order and repetition do not affect compound identity. The type-tagged tuple
preserves category distinctions: strings compare exactly, including empty
strings; numbers, booleans, and null remain distinct categories. Finite numbers
compare by exact mathematical value, so `1`, `1.0`, and `1e0` are equal, as are
`0` and `-0`, without floating-point rounding. Distinct tuples append in source
and list order; duplicate tuples conflict even when records are otherwise equal
or disjoint. Malformed records produce `compose.invalid`; duplicate identities
produce `compose.conflict`, with paths and source locations identifying a
duplicate without exposing identity values. Missing paths and non-sequence values
do not fail by themselves. Equivalent overlapping registrations coalesce.
Incompatible overlaps and invalid registrations produce `options.invalid` before
source acquisition. Unused and root registrations are allowed and have no effect.
Registrations provide identity for strict composition, sequence operations,
inheritance lookup, and final validation. Dynamic selectors apply at concrete
strict-composition, inheritance, and final-result destinations without requiring
the application to enumerate mapping keys. Each strict tier has independent
duplicate-identity state among its peers; layered precedence and inheritance
then apply the collection operation described below.
Final registration checks run in both ordinary and layered loads after effective
secret lookup and before application semantic validation. They examine actual
final typed sequences, including values introduced by defaults, CLI, or recorded
resolution; they do not require path existence or sequence shape. Diagnostics
use concrete collection and item paths and available source locations, without
exposing identity or credential values. The guarantee ends when loading publishes
successfully and does not cover later caller mutations.
YAML-only identity is enforced during structural composition, precedence, and
identified inheritance; it carries no final-result guarantee after typed
defaults, CLI, or application resolution. Applications that need that guarantee
register the identity constraint.
The [maintained catalog examples](consumers/README.md#identified-prompt-sequences)
show strict peers, precedence operations, inheritance, dynamic destinations, and
the distinction between YAML-only declarations and application registrations.
When an enabled source-version mode is selected, declarations are checked in each
source's own coordinates before placement and composition. Accepted non-main
headers are consumed before strict composition and cannot supply a registered
record identity. Final typed decoding and application validation still run on the
completed configuration.
Ordinary sources, layered inputs, and selected overlays may declare collection
behavior under `configkit.sequences` at the document root. Each entry has a
required literal `destination` and may specify `key` as one string or a nonempty
list of strings, plus a `mode` of `replace`, `append`, or `upsert`. These
controls are removed before application decoding. A declaration destination is
relative to that source's placement, so a mounted fragment's declaration is
mounted with its values. Declarations from strict peers must agree on identity
fields and on any explicitly supplied mode; omission expresses no mode
preference. They apply even when the declaring source has no value at that
destination: a key validates an existing retained sequence or remains pending
until records arrive, while a mode-only declaration affects later combinations
without recombining earlier tiers. A key supplies the identity used by `append`
and `upsert` and is checked against any application registration.
Strict peers at an identified destination first combine distinct records in
source and list order. Duplicate identities among peers or within one
contribution fail under every mode. After strict composition, precedence and
inheritance use one operation for the assembled incoming collection:
| Mode | Behavior at source-precedence and inheritance boundaries |
| --- | --- |
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
| `append` | Retain lower records and append higher records; duplicate identities fail. |
| `upsert` | Replace matching records in full at their lower positions, retain unmatched lower records, and append new identities. |
Upsert replaces the complete matched record; it does not recursively merge
record fields. Append and upsert require identity. With no explicit mode in the
current tier, an explicit lower mode persists; if none exists, effective identity
selects upsert and its absence selects replace. Inferred modes do not persist.
Explicit modes persist at their destination until changed by a higher explicit
mode, independently of YAML identity lifetime. A declaration alone does not
clear or recombine a collection.
The presence and value kind at a precedence or inheritance boundary determine
whether a collection changes:
| Higher contribution | `replace` | `append` / `upsert` |
| --- | --- | --- |
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
| Explicit `[]` | Clear the sequence. | Contribute no records. |
| Explicit null or another non-sequence value | Replace the lower value, subject to application schema. | Error; the operation requires a sequence. |
An absent lower value can be initialized by an incoming sequence under any mode.
A present lower null or other non-sequence value is not absence: append and
upsert cannot combine with it. An empty sequence still requires a valid mode and
identity where applicable. Null used as an identity component inside a record is
distinct from null used as the collection value.
Declarations govern ordinary strict source composition, including loads with
imports disabled, and each strict scope in layered loading. Before composing a
higher tier's peers, ConfigKit makes applicable lower identity and explicit mode
available; after peer composition, the tier applies its resolved operation
against the lower collection. Selected overlays use the same collection policy
after the base source graph is composed. During mapping-sibling inheritance, a
parent's YAML identity can follow its collection to the child destination, while
the parent's explicit operation stays at the parent destination. The child
destination selects its operation; absent one, effective identity implies
upsert and its absence implies replace. Whole-sequence replacement discards
lower YAML identity and uses the replacing scope's key declaration, if any;
without one, the collection is unkeyed unless an application registration still
supplies identity. Replacement discards policies owned by removed records, while
an independently declared explicit mode and application registration remain
applicable. Item-local
policies follow their owning records: retaining a record retains its rules, while
whole-record upsert replacement discards the displaced record's rules. Matching
identity or reusing an index does not transfer them. These policies do not change
CLI behavior: CLI sequence assignments replace whole sequences after decoding.
A root `configkit.imports` declaration still requires `Options.Imports`;
`NoSource` has no declaration scope.
An empty mapping contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite
base origin. A conflict reports the first claim as its primary location and every
remaining claim as related locations, in deterministic source and declaration
traversal order. Traversal order never authorizes replacement. Composition clones
the resulting tree and its ordered claim records rather than mutating or aliasing an
input document.
base origin. A conflict reports the first claim in
[scoped assembly order](#explicit-imports) as its primary location and every
remaining claim as related locations in the same order. Assembly order never
authorizes replacement. Composition clones the resulting tree and its ordered
claim records rather than mutating or aliasing an input document.
## Selected overlays
@@ -670,13 +1049,21 @@ Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, wi
no extension fallback, case normalization, inheritance between overlay files,
or overlay stack. A selected missing overlay is an error.
With `NoSource`, the main input stays absent and the explicit filesystem context
supplies the overlay root. The overlay is recorded as the first acquired source
when no other source exists. Host contexts may use the root-only form described
under [single-source loading](#single-source-loading); native contexts retain
their complete namespace, base, and root requirements.
The overlay is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults
and typed decoding. Overlay bytes, nodes, and source records count toward the same
aggregate limits as base inputs. Its canonical path must remain within the configured
root and must not duplicate a file already present in the base graph.
Overlay is intentionally distinct from strict composition:
Collection destinations follow the
[collection operation and presence rules](#strict-document-composition) above.
The following table describes ordinary overlay behavior elsewhere:
| Existing lower node | Incoming overlay node | Result |
| --- | --- | --- |
@@ -685,18 +1072,24 @@ Overlay is intentionally distinct from strict composition:
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
cross-kind replacement is allowed when the final application schema accepts it.
Under these ordinary rules, scalars, sequences, and null are atomic. An overlay
sequence replaces rather than extends a base sequence, `[]` clears it, null is an
explicit replacement, and cross-kind replacement is allowed when the final
application schema accepts it.
Replacing a subtree removes all displaced descendants from effective presence and
origin data; history retains the displaced definitions and identifies the parent
replacement. Surviving overlay paths have overlay origins, while recursively shared
mapping containers retain their contributing locations.
An overlay may not contain `configkit` anywhere, contribute the configured schema
version, or replace an ancestor of that version with a scalar or null. These rules
apply even if the supplied version text would be unchanged. Overlays cannot mask a
base parse, composition, or version failure because those checks run first.
An overlay may contain only root `configkit.sequences` declarations; overlay
imports and nested directives are rejected. Sequence declarations are removed
before application decoding. At declared or registered sequence destinations,
`replace`, `append`, and `upsert` use the same identity and policy rules as source
composition. Other overlay sequences remain atomic replacements. An overlay may
not contribute the configured schema version or replace an ancestor of that
version with a scalar or null. These rules apply even if the supplied version
text would be unchanged. Overlays cannot mask a base parse, composition, or
version failure because those checks run first.
## Explicit CLI overrides
@@ -708,10 +1101,16 @@ must never contain the raw argument or configured value. General environment
variable overlays are an explicit non-goal: environment access is reserved for
typed secret references.
Overrides apply after the selected overlay and before typed defaults/decoding, so
the input precedence is application defaults < composed base < selected overlay <
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
effective override tree. Overrides also work with `NoSource`.
Overrides are prepared after source assembly and inheritance, then assigned after
typed defaults and decoding and before application resolution. The order is:
defaults and decoded sources < CLI < application resolution. A CLI mapping
recursively overlays the decoded mapping; a CLI sequence replaces the complete
decoded sequence regardless of source collection operations. Inheritance and
typed defaulting do not run again after CLI assignment. Earlier parsing,
composition, inheritance, and decoding failures remain errors even if an override
would replace the failing value. Recorded resolution, typed secret lookup, and
semantic validation use the effective post-override values. Overrides also work
with `NoSource`.
Values are converted directly into ConfigKit's owned tree without YAML parsing or
string coercion. Supported values are `nil`, built-in strings and booleans, all
@@ -722,7 +1121,8 @@ false, and zero remain intentional; empty sequences replace whole lists; and map
values recursively overlay existing mappings. Map keys are sorted canonically and
sequence order is preserved.
All records are detached and validated before any is applied. Root and explicit
All records are detached and validated before any is applied. They cannot create
`configkit` or `extends` controls. Root and explicit
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
may create missing mapping ancestors but cannot traverse an existing scalar, null,
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
@@ -739,8 +1139,12 @@ unsupported types, nonfinite floats, and limit excesses fail with safe `override
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
arguments or values.
Override origins are explicit CLI origins. History retains replaced base/overlay
definitions and records equal-value replacements as well as structural changes.
Override origins are explicit CLI origins. Resolution receives CLI values with
their explicit-presence and source information. Absence-based defaults preserve
those values, while recorded normalization and derivation may change them. History
retains replaced source definitions and records equal-value replacements as well
as structural changes. Secret lookup and validation use post-resolution values
with their effective source locations.
Caller-owned maps and slices are snapshotted during loading and must not be mutated
concurrently with the call. The executable `ExampleOverride` shows translating a
parser's explicit-set state into records while preserving an explicitly supplied
@@ -748,12 +1152,22 @@ zero.
## Explicit imports
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
fields are `import.invalid`. A `configkit` key below the root is invalid. With
imports disabled, any occurrence remains `import.disabled`.
Imports are opt-in. With `Options.Imports` enabled, a document may use its root
`configkit` mapping for `imports` and `sequences` controls. Ordinary `Load` may use
`sequences` without enabling imports, but an `imports` field still requires
`Options.Imports`. ConfigKit removes recognized root controls before application
decoding. Each imports sequence item is a mapping with exactly one `path`,
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
`glob` accepts one path pattern string. The optional `required` boolean controls
whether an empty selection or a missing named path is an error. Its default is
false, so missing imports are absent unless `required: true` is specified. The
removed `optional` field is invalid. `destination` is an optional sequence of
strings. Every destination string is one literal mapping key and follows the
input-destination validation rules above; quote numeric-looking keys so YAML
reads them as strings. Null, scalar destinations, and non-string members are
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
key below the root is invalid. With imports disabled, root imports and nested
`configkit` keys remain `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to
@@ -762,25 +1176,118 @@ directive keys in root files and imported fragments, and rename any application
fields previously named `configkit` to avoid the newly reserved key.
Path declarations use slash-separated relative paths. Absolute paths and network
URLs are invalid. Each path resolves from its declaring file's canonical directory,
and files are traversed depth-first in declaration order: a file contributes its own
body before its imports. Order stabilizes source records and diagnostics but never
permits replacement. Required missing paths fail; `optional: true` suppresses only
target nonexistence, not invalid paths, containment failures, directories,
permissions, parse errors, or composition errors.
URLs are invalid. Each path resolves from its declaring file's canonical directory.
An empty `path: []` selects nothing; it succeeds even when `required: true` because
there are no named targets to be missing. A nonempty required path list verifies
every named target, including targets later excluded from selection.
Directory declarations search the listed literal directory roots. Missing roots
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
accepts one basename pattern or a list, matching filenames only. Omission selects
all filenames; an explicit empty list selects none. `exclude` accepts one basename
pattern or a list and removes matching final candidates. Patterns use Go's
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
`depth` is valid only when recursion is effective, with direct files at depth 1 and
depth 0 or omission meaning unlimited traversal subject to library budgets.
`follow` controls discovered symlink entries and `hidden` controls discovered
names beginning with a dot. Both default to true. Explicit roots retain their
normal access behavior.
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
Backslashes escape pattern characters rather than acting as directory separators.
ConfigKit validates the complete pattern before filesystem traversal, walks only
directories selected by preceding segments, and reads wildcard directories in
bounded batches. Every examined entry consumes the separate visit budget, including
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
each declaration before depth-first source traversal. A required zero-match glob is
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
walk failures, matched directories, invalid documents, escapes, and repeated targets
remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated.
classes, and backslash escapes are supported, while recursive `**` syntax is
invalid. Backslashes escape pattern characters rather than acting as directory
separators. ConfigKit validates the complete pattern before filesystem traversal,
walks only directories selected by preceding segments, and reads wildcard
directories in bounded batches. Every examined entry consumes the separate visit
budget, including nonmatches. Literal components are explicit path components;
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
final candidate basenames.
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` provide one
application-owned load-wide policy for enabled imports in the main source,
underlays, and transitive imports; they do not apply to selected overlays or
discover sources by themselves. Each applicable field resolves independently:
an explicit declaration value wins, then the application default, then the
library default (`required: false`, `follow: true`, `hidden: true`,
`recursive: false`, and unrestricted filenames). A declaration's `pattern`
replaces rather than extends the default pattern. Nil `Pattern` leaves directory
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
the supplied pointers and slice before source acquisition.
A source's `configkit.sequences` declarations apply independently of import
enablement; a `configkit.imports` declaration still requires `Options.Imports`.
Sequence identities can be supplied either by these YAML declarations or by
application registrations. The [strict composition contract](#strict-document-composition)
defines their agreement, operation, and lifetime rules.
Acquisition visits declarations in authored order and each declaration's selected
paths in namespace-path lexical order, preserving source-record and version-callback
order. Ordinary mapping contributions in a main tier are assembled dependency-first:
ConfigKit selects the lexically smallest stable source key among the contributions
whose imports are complete. Independent branches may interleave, and the main body
may be assembled after its imports. Sequence-item contributions in that tier are
nodes in the same schedule; each selected item first assembles its own strict
mapping scope, with helpers before the bodies that import them. Those local scopes
do not use application collection policies and are not flattened into the outer
schedule. Item positions and diagnostic paths follow the resulting assembly order.
Each underlay group likewise acquires its inputs before dependency-ready assembly
in one strict scope. Independent groups remain separate precedence tiers; at a
declared or registered destination, the assembled higher sequence uses the
resolved collection operation against the lower sequence. Other sequences retain
whole-value replacement between tiers. Assembly order never permits replacement.
Acquisition order and resolution order serve different purposes: acquisition
assigns source records and callback order, while the shared scheduler determines
composition order among currently dependency-ready contributions. Inheritance
uses the same eligibility rule within each destination-local sibling scope; it
does not reorder the authored output mapping. The private namespace and stable-key
construction is described in the [import implementation overview](internal/imports.md).
An empty selection is allowed by default. `required: true` makes an empty directory
or glob selection fail with `import.missing`; it also makes missing named paths
fail. Requiredness does not suppress invalid paths, containment failures,
directories selected as files, permissions, parse errors, or composition errors.
For inspection, `SourceInfo.ImportSelections` records one outcome for each
completed optional declaration whose entire selected set is empty. The record is
attached to its declaring source, keeps the declaration path local to that source,
identifies the declaration mapping (including its `SourceID`) and selector, and
uses outcome `none`. Records remain in declaration order and are available
through the existing result, load
error, and callback source snapshots. They do not enumerate candidates, explain
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
regardless of requiredness: it produces a `none` record when effectively optional,
and no record when effectively required. Snapshots detach the record paths and
slices.
Each declaration may include `placement: mapping` or
`placement: sequence-item`. Omission means `mapping` for that declaration,
including every match of a glob; placement is not inherited. Sequence-item
placement requires a nonempty effective destination and wraps the imported
mapping as one element of a sequence there. It does not split source sequences
or infer identity fields. Repeated sequences combine only when the destination
has an applicable YAML identity declaration or application registration; source
placement remains independent of collection identity and operation. Sequence
records retain source locations through placement and history; indexes describe
their assembled positions while source coordinates continue to identify the
declaring files.
Header/version policy retains its existing owner and timing. Application
registrations also enforce identity on the final effective typed configuration;
YAML-only declarations are enforced at structural combination and inheritance
boundaries. The [configuration contract](#strict-document-composition) defines
these validation boundaries.
An item import first opens a local mapping scope. Its body and ordinary helper
imports compose within that record. An omitted helper destination or
`destination: []` places the helper at the record root; other destinations are
relative to the immediate importing source. A helper may supply fields such as
the record's `id`. An explicit nested `sequence-item` import creates a sequence
inside the record, and its placement is independent of the enclosing item.
At the record root, an item import must name a nonempty nested destination.
Physical paths continue to resolve from the file that declares each import.
An actual empty mapping contributes an empty item, while an unmatched declaration
with default or explicit `required: false` contributes nothing.
For host files, the configuration root defaults to the selected file's canonical
directory. A host file may supply `FilesystemContext.Root` to widen that root but
@@ -799,8 +1306,13 @@ ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
distinct paths remain distinct sources. Native filesystems use slash-path operations
and their own symlink behavior; imports stay in their selected namespace and root.
distinct paths remain distinct sources. Native filesystems use slash-path
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
that capability to inspect and resolve discovered links; otherwise it follows
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
native filesystem to expose link inspection, and native filesystem behavior is
not a general sandbox guarantee. Imports stay in their selected namespace and
root.
An active-stack repeat is `import.cycle` and reports the complete cycle's import
sites. A previously visited canonical path is `import.repeated` and reports both

View File

@@ -6,6 +6,30 @@ applications. Consumers still own their configuration structs, defaults, semanti
validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization.
## Choosing a loading entry point
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
[`SearchConfig`](../../discovery.go) is also a source, so its explicit
discovery result can be passed to `Load` or used as the main input to
[`LoadLayered`](../../layered.go).
- Use `LoadLayered` when inputs must be composed in underlay groups before the
main source. Each [`Input`](../../layered.go) owns its source, filesystem,
destination, and import choice. The overlay filesystem context is selected
independently in [`LayeredOptions`](../../layered.go).
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
[`LayeredOptions`](../../layered.go): the application definition (including
version policy), import defaults, and explicit overrides. These do not turn
on imports or discovery by themselves. See the
[configuration contract](../config.md#layered-loading) and
[discovery contract](../config.md#configuration-discovery) for exact rules.
The root [README example](../../README.md) shows the small `Load` path.
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
file and layered cases. For import-focused adoption, see the
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
discovery composed with sequences, see the
[sequence catalog](../../examples/consumers/sequence_catalog.go).
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library:
@@ -17,8 +41,11 @@ sanitized shapes using only ConfigKit and the Go standard library:
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
@@ -52,6 +79,51 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts.
## Explicit main-file discovery
Applications choose the search conventions and their precedence, then pass the
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
For example, the maintained sequence catalog puts a caller-selected file before
a custom directory fallback while keeping its underlay and overlay choices
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
the [working-directory executable example](../../examples/consumers/discovery_test.go).
Together, the root-owned [discovery API](../../discovery.go) and [configuration
discovery contract](../config.md#configuration-discovery) define ordered
concatenation, directory-first Cartesian expansion, optional exhaustion, and
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records why discovery is explicitly selected.
## Import defaults and selection inspection
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
load-wide policy to enabled main, underlay, and transitive imports. Each field
is resolved independently: a declaration's explicit value wins, then the
application default, then the library default. `Required`, `Follow`, `Hidden`,
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
directory searches: nil leaves filenames unrestricted, while a non-nil empty
slice selects no files. A declaration's own `pattern` replaces the application
default. The defaults do not enable imports or change selected-overlay behavior.
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
declaring source's `ImportSelections`. These records identify the source-local
declaration path, its authored location and selector, and the `none` outcome;
they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics.
## Shared source-version checks
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption,
main-path protection, default behavior, and evidence disclosure. Accepted
declarations do not replace final assembled decoding or application validation.
## Effective configuration diffs
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
@@ -108,6 +180,79 @@ and may fail independently of the completed configuration load. The
[configuration contract](../config.md#source-resource-contexts) owns exact
lifetime and precedence rules.
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
counterpart: its entry opts into imports through `Load`, imports metadata at the
root, and places a prompt beneath `prompts` using YAML. The executable example
and behavior test are in
[`layered_test.go`](../../examples/consumers/layered_test.go).
## Input destinations and mounted collections
Main and underlay inputs use the same destination field. An import without a
`destination` inherits its declaring document's effective destination; an
explicit sequence of key strings appends a relative suffix. Nested imports
append to their immediate parent's effective destination, while filesystem
paths remain relative to the declaring file. Destinations are application
mapping paths, independent of filesystem context. The configuration contract
owns the complete behavior and validation rules.
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
mounts one mapping source beneath literal collection/prompt keys and loads a
second input without a destination to show that root placement remains the
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
to interpret.
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
embedded collection manifest mounted beneath a literal collection path. The
entry's YAML places nested metadata imports at the collection root and prompt
and profile peers beneath their own keys. A higher-priority input changes only
one prompt's ordinary `version` field. The main document alone supplies the
application schema version. A prompt's local `extends` relationship resolves
after all tiers, so `custom` inherits the final `brief` content and fields while
retaining its own profile selection.
The resolver follows each effective `content_file` origin to its declaring
source context. This also works for `custom`'s inherited resource. The
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
demonstrates the same lookup after loading; ConfigKit does not open or interpret
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
checks source destinations and authored coordinates, presence, inherited values,
relative resources, and strict peer conflicts. The
[configuration contract](../config.md#input-destinations) owns shared placement,
import, source-location, and ownership behavior.
## Identified prompt sequences
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) shows the
smaller import-focused adoption path: a registered `prompts.id` identity,
directory and glob selection, a helper import that supplies an item's ID, and
resource lookup through the effective source context. Its executable examples
and tests are in
[`prompt_catalog_test.go`](../../examples/consumers/prompt_catalog_test.go), with
complete [catalog](../../examples/consumers/testdata/prompt-catalog/config.yml)
and [required-file](../../examples/consumers/testdata/prompt-catalog/simple.yml)
fixtures.
[`LoadSequenceCatalog`](../../examples/consumers/sequence_catalog.go) integrates
an underlay declaration, imported main fragments, identified-record inheritance,
selected overlay `append`, `upsert`, and `replace`, a typed CLI override, and
recorded application resolution. Its tests and runnable example demonstrate
stable output order, whole-record upsert, provenance/history inspection,
resource lookup for inherited records, and secret-safe explanation in
[`sequence_catalog_test.go`](../../examples/consumers/sequence_catalog_test.go).
[`LoadNamespacedMapCatalog`](../../examples/consumers/sequence_catalog.go) adds
an exact root registration and a wildcard namespace registration, showing that
each concrete sequence has an independent identity scope. The same tests show
that a YAML identity declaration without an application registration does not
create final-result identity enforcement.
The examples keep prompt meaning and domain checks in the consuming application.
The [configuration contract](../config.md#strict-document-composition) owns
declaration, identity, operation, ownership, and final-validation semantics;
[sibling inheritance](../config.md#sibling-inheritance) defines record
relationships, and the [CLI contract](../config.md#explicit-cli-overrides) owns
override ordering.
Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime-
@@ -187,6 +332,7 @@ focused behavioral owner:
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, 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 |
| 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` |
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |

View File

@@ -39,9 +39,11 @@ public behavior.
| Task | Read |
| --- | --- |
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
| Documentation changes | The owning document |
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
| Architecture decisions or future work | [Future features](roadmap/future.md), [consolidated audit review](roadmap/audit.md), [remediation roadmap](roadmap/roadmap.md), and relevant ADRs listed below |
| Code and test changes | The owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release procedure](release.md) |
@@ -49,12 +51,19 @@ public behavior.
Accepted decisions:
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md)
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.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)
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -104,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
```
Remaining optimization candidates belong to
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
[future performance experiments](roadmap/future.md#5-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under
@@ -113,6 +122,89 @@ resolved review findings are preserved in Git history at `1c2610c` under
comparison and disclosure behavior, and
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
The completed input-destination, relative-import-destination, and shared
source-version roadmaps, associated consumer request, implementation plan, and
resolved review findings are preserved in Git history at `e4270c9` under
`docs/roadmap/`. For example:
```sh
git show e4270c9:docs/roadmap/implementation.md
git show e4270c9:docs/roadmap/remediation.md
```
Current behavior belongs to the configuration contract's
[input destinations](config.md#input-destinations),
[imports](config.md#explicit-imports), and
[shared source-version checks](config.md#shared-source-version-checks) sections.
Explicit sequence-item placement, collection operations, identity registration,
and final registered-identity validation are owned by the
[strict composition](config.md#strict-document-composition) section;
[imports](config.md#explicit-imports) owns acquisition and scheduling. The
[maintained sequence catalog](consumers/README.md#identified-prompt-sequences)
shows the consumer boundary, and [ADR 0017](adr/0017-collection-sequence-policy.md)
records the collection decision.
The completed sequence-composition roadmap, implementation plan, and resolved
remediation record are preserved in Git history at `008d2ba` under
`docs/roadmap/`. For example:
```sh
git show 008d2ba:docs/roadmap/sequence-composition.md
git show 008d2ba:docs/roadmap/implementation.md
git show 008d2ba:docs/roadmap/remediation.md
```
The completed import-discovery roadmap, implementation plan, and resolved
remediation record are preserved in Git history at `5747ec4` under
`docs/roadmap/`. For example:
```sh
git show 5747ec4:docs/roadmap/imports.md
git show 5747ec4:docs/roadmap/implementation.md
git show 5747ec4:docs/roadmap/remediation.md
```
The subsequent import-integration audit and completed remediation record are
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
`git show 6903c9e:docs/roadmap/remediation.md`.
The completed sequence-identity/collection-policy roadmap and implementation
plan are preserved in Git history at `d0a605a`:
```sh
git show d0a605a:docs/roadmap/sequences.md
git show d0a605a:docs/roadmap/implementation.md
```
Current behavior belongs to the configuration contract's
[strict composition](config.md#strict-document-composition),
[sibling inheritance](config.md#sibling-inheritance), and
[CLI overrides](config.md#explicit-cli-overrides) sections.
The completed remediation record, including the algorithm-audit findings, is
preserved at `ea46001:docs/roadmap/remediation.md`.
The completed configuration-discovery roadmap, implementation plan, and
remediation record are preserved in Git history at `0369111`, for example
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
[configuration discovery](config.md#configuration-discovery); the public
[discovery declarations](../discovery.go) own the Go API.
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).
The [consolidated audit review](roadmap/audit.md) owns the open findings from
the nine 2026-10-25 audits, their evidence, and settled scope decisions. The
[remediation roadmap](roadmap/roadmap.md) owns the combined target state and
high-level dependency order. The original reports are preserved at `ecd5d5d`
under `docs/roadmap/`.
Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline
development or validation environment:
@@ -159,19 +251,25 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command |
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| 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'` |
| 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 | [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'` |
| 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'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |

125
docs/internal/imports.md Normal file
View File

@@ -0,0 +1,125 @@
# Import Discovery and Scheduling Internals
The [configuration contract](../config.md#explicit-imports) owns selector syntax,
defaults, requiredness, placement, inspection evidence, and observable ordering.
This note records implementation flow and private identities for maintainers.
## Discovery and acquisition
Each source's root directive is parsed into presence-aware declarations and
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
policy, load-wide accounting, and tier-scoped repetition/namespace state;
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
and import admission. Candidate
selection in `import_selection.go` preserves a normalized
namespace path separately from canonical access identity: overlapping roots
coalesce the same candidate spelling in that namespace, while aliases reached
through distinct spellings retain separate inclusion routes for admission.
Directory searches walk each distinct normalized root once, apply all filename
patterns to discovered names, and form a set union of matching candidates across
overlapping roots. Exclusions are applied as set difference after discovery and
candidate normalization. They do not suppress discovery or resource errors.
Directory traversal tracks canonical directories on the active traversal path.
It adds an identity when descending and removes it when the frame is popped, so
only a back-edge to an active directory is a traversal cycle; a later alias is
not mistaken for one. This is separate from import dependency-cycle detection.
For host sources, discovery uses rooted `Lstat` and canonical containment.
`filesystem_context.go` owns resolved access facts and live access setup; the
acquisition operation closes its live handles before composition. Native sources
use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
capabilities and behavior of the provided `fs.FS`, without claiming a general
sandbox guarantee. `source_context.go` derives passive resource contexts from
resolved sources for later caller-owned reads; those records do not retain live
acquisition handles.
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
selected candidate set in namespace-path lexical order. This preserves source
records, IDs, source-local version checks, and callback ordering. The acquired
inclusion graph is retained separately from composition scopes so cycles that
cross item boundaries remain detectable. Source discovery does not itself choose
sequence assembly order.
## Stable keys and scheduling
The shared scheduler compares immutable tuples component by component using Go's
bytewise string order; if one tuple is a prefix of another, the shorter tuple
sorts first. The import key is
`(namespace-kind, namespace-anchor, resource-kind, resource-path)`:
- Namespace kind is `host` or `native`. Host resource paths are canonical
absolute paths rendered with slash separators; native paths retain their
namespace-relative identity.
- Anchors come from explicit input positions, not discovery order: main is
`(0,0)` and underlay/input positions are `(group+1,input+1)`, each encoded as
fixed-width decimal. Host accesses in one scheduled tier share its host anchor.
A native filesystem namespace uses its earliest explicit selecting position
in that tier. Filesystem tokens distinguish namespace equality, not sort order,
so separately constructed filesystems with the same label remain distinct.
- Resource kind is `file` or `synthetic`. A synthetic root uses its explicit
input anchor and an empty resource path rather than a display label or token
address.
Inheritance keys are
`("definition", scope-pointer, definition-pointer)`, where both pointers use
canonical escaped semantic path spelling. Source IDs are not part of the key.
Both integrations use the same Kahn scheduler: it deduplicates dependency edges,
keeps currently ready nodes in a min-heap, and selects the smallest ready key
after every completion. Its scheduling cost is `O(E + V log V)` time and
`O(V + E)` additional memory, excluding key construction, tuple copying, and
tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
returns a closed cycle rotated to its smallest key; blocked dependents are not
reported as cycle members.
Import edge preparation builds an importer-to-target adjacency index once for
an acquisition's outer and item-local scheduling, and once for grouped
scheduling. Each scope gathers edges from its participating identities and
retains only edges with both endpoints in its local slots. The retained
inclusion graph still owns cycle diagnostics and source relationships.
Import composition schedules a main tier or one underlay group as a single
strict-scope graph. Applicable collection declarations are gathered for the
scope before peer composition, including identity inherited from lower tiers.
Identified peer sequences then combine distinct records in assembled source and
list order; after the strict scope completes, layered assembly applies the
resolved collection operation against the lower value, as defined by the
[configuration contract](../config.md#strict-document-composition). Item
contributions own nested local strict-mapping scopes; their helpers complete
before the item is placed in its outer scope. Inheritance uses the same
ready-node rule inside each sibling mapping scope, while preserving the authored
mapping order in its output.
These scopes do not flatten into one global graph, and independent precedence
tiers remain separate.
The owners are [`acquisition.go`](../../internal/engine/acquisition.go),
[`import_selection.go`](../../internal/engine/import_selection.go),
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
[`directory_walk.go`](../../internal/engine/directory_walk.go),
[`import_graph.go`](../../internal/engine/import_graph.go),
[`import_projection.go`](../../internal/engine/import_projection.go),
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
Their focused behavior is covered by import-selection, acquisition, scheduler,
and scheduling integration tests under `internal/engine`; see the
[development test map](../development.md#task-to-test-map).

View File

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

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3
**Last Updated**: 2026-09-29
**Last Updated**: 2026-10-22
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
@@ -19,8 +19,8 @@ to ADRs for decision rationale.
ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. The [component overview](../internal/overview.md)
owns implementation inventory, the [configuration contract](../config.md) owns
implemented behavior, and the [deferred scope](../roadmap/deferred.md) preserves
deferred scope.
implemented behavior, and the [future feature list](../roadmap/future.md) preserves
candidates for future consideration.
## Adopted Constraints And Implemented Structure
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
Constraints must reflect deliberately selected requirements and decisions.
Do not promote incidental implementation choices into architectural invariants.
The implemented collection-policy and CLI-ordering constraints below reflect
accepted [ADR 0017](../adr/0017-collection-sequence-policy.md) and
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The configuration
contract owns their current behavior; these architectural constraints describe
the system boundaries and guarantees that guide changes to that behavior.
## Success Criteria
ConfigKit prioritizes a coherent configuration model: consistency and
@@ -91,17 +97,82 @@ boundaries, and keep simple consumers independent of optional composition
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
implemented surface against representative simple and complex shapes.
## Simplicity, Defaults, And Explicit Controls
These principles apply throughout ConfigKit: to configuration syntax, the public
Go API, defaults, and feature design. Evaluate their effects on both configuration
authors and consuming applications.
**Keep straightforward workflows straightforward.** Common configuration
workflows should require little configuration text and minimal API setup.
Support more demanding workflows through explicit, composable options, without
requiring simple consumers to understand or configure those options.
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
defaults that minimize required setup and accommodate valid configurations
without unnecessary restrictions. Provide selectable options for workflows that
need additional capabilities, narrower behavior, or stronger requirements.
Measure simplicity by what consumers must understand and maintain, including
configuration text and application-side plumbing. Concise syntax is useful, but
hidden inference and interacting meanings can make a shorter declaration harder
to understand. Prefer explicitness where it makes behavior more predictable.
A terse configuration format that requires substantial application-side
infrastructure does not satisfy the simplicity principle.
Define what permissiveness means for each behavior. Allowing an optional
contribution to be absent or accepting valid inputs without an unnecessary
filename restriction can reduce required setup. Invalid declarations, ambiguous
composition, and unreadable selected sources must still produce meaningful
errors under the applicable contracts. Permissiveness does not authorize silent
recovery from invalid supplied configuration or weaken configuration integrity.
Options should express coherent choices with understandable purposes and fit
ConfigKit's shared configuration model. Support powerful controls when they
serve concrete needs; these principles do not require a switch for every
implementation behavior or conceivable consumer preference.
Defaults require judgment when simplicity and permissiveness pull in different
directions. Broader discovery can accommodate more layouts while increasing the
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
not mechanically select the broadest behavior for every option. Changes to
existing defaults remain subject to compatibility review, and proposed behavior
remains in the roadmap until implemented.
## Responsibility Boundaries
ConfigKit owns configuration input processing and the coordination of decoding,
resolution, validation, and inspection. Applications own their configuration
types, field meanings, defaults, normalization, semantic validation, and schema
version policy. Applications also own main-file search conventions, CLI wiring,
remote acquisition, runtime initialization, and resource checks.
version policy. Applications choose and order their main-file search conventions;
ConfigKit provides explicitly selected search descriptions that resolve during
loading. Applications also own CLI wiring, remote acquisition, runtime
initialization, and resource checks. See the [configuration discovery
contract](../config.md#configuration-discovery) and [ADR
0019](../adr/0019-explicit-configuration-discovery.md).
The engine applies each input's mapping destination within the existing source
assembly, applies immediate-parent-relative YAML destinations to imports, and
retains each effective placement with source identity and inspection records;
the root facade owns the public fields and conversions. The
[configuration contract](../config.md#input-destinations) defines the observable
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
the import extension.
Library code must not depend on consuming applications or initialize their
runtime resources. It must not perform implicit network access, discover
application-specific paths, or change process-global state during ordinary `Load`.
runtime resources. It must not perform implicit network access, implicitly
discover application-specific paths, or change process-global state during
ordinary `Load`.
Import discovery is available only through declarations in selected sources:
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
ConfigKit does not search conventional application paths unless the application
explicitly supplies a discovery description, and it never discovers a main
configuration file implicitly. Imports and sibling inheritance share
dependency-ready scheduling within their existing scopes; acquisition order and
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
invoke it during startup, and it reports writes, skips and partial failures. It
must never run implicitly as part of configuration loading.
@@ -124,6 +195,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
the precise completion and ownership behavior. The proposed decision record is
[ADR 0008](../adr/0008-complete-on-return-loading.md).
### Accepted Processing Order
Complete source composition and precedence, including selected overlays, before
sibling inheritance. Then apply typed defaults and decode the configuration.
Apply explicit CLI overrides to that configuration and check their types before
running application resolution. Effective secret lookup and final validation
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
records this ordering.
CLI overrides are the highest-priority input to application resolution. Supplied
sequences replace whole sequences, and mappings combine recursively. Inheritance
and typed defaulting do not run again after CLI application. CLI paths and values
must not introduce structural directives; earlier source, inheritance, and
decoding failures remain errors even if a CLI assignment would replace the value.
Semantic validation assesses the final configuration after overrides and resolution.
Applications own flag parsing and domain resolution. ConfigKit owns override
application, validation, and source attribution. Resolution receives CLI values
with their explicit-presence and source information. Absence-based defaults
preserve explicit values; recorded normalization and derivation can still change
them. Typed defaults and application resolution retain their own assignment
semantics, independently of collection operations during source assembly.
## Public API And Dependency Direction
Keep one small, coherent public surface for the supported lifecycle. Simple
@@ -152,31 +246,44 @@ framework on this library.
## Configuration Integrity
Within a configuration tier, independent contributions use strict composition:
shared mappings combine recursively, while overlapping non-mapping definitions
are conflicts, including equal values. Between precedence tiers, intentional
overlay combines mappings recursively, retains omitted lower-priority children,
and replaces other present values according to the existing overlay rules.
Tier precedence is explicit; traversal order within a tier establishes stable
reporting, not permission to override. The distinction and its rationale are
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
Within a configuration tier, independent contributions use strict composition.
Shared mappings combine recursively. Sequences with an applicable identity
combine distinct records and reject duplicate identities, including equal
records. Other overlapping non-mapping values remain conflicts, including equal
values. Conflicting identity or operation declarations among peers also fail.
Traversal order establishes stable reporting and record order; it does not grant
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
records the distinction between strict composition and intentional overrides.
The main document and its imports form a strictly composed tier. Explicit
underlay groups compose strictly within each group, then combine recursively
from lowest to highest beneath the main tier. Source acquisition kind does not
determine its precedence role. Input-specific constraints, including CLI path
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
semantics do not require identical acquisition or lifetime behavior from every
source kind.
underlay groups compose strictly within each group, then combine from lowest to
highest beneath the main tier. Between tiers, mappings combine recursively and
retain omitted lower-priority children. Sequence values follow collection policy;
other present values retain the established replacement rules. Source acquisition
kind does not determine precedence. Input-specific constraints, including version
ownership and path protections, remain part of the configuration contract.
Consistent combination semantics do not require identical acquisition or lifetime
behavior from every source kind.
After source precedence is complete, built-in sibling `extends` relationships
resolve structurally in the same parser-independent representation, using the
existing recursive overlay rules. This adds neither an acquired source nor a
consumer registration mechanism. Enclosing relationships precede nested
destination-local resolution; typed defaults, application resolution, and
secret lookup retain their existing lifecycle roles. The [configuration
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
Built-in sibling `extends` relationships resolve after source precedence in the
parser-independent document representation. Preserve existing mapping-sibling
scopes and allow an identified sequence to provide a sibling scope for its
records. A record may inherit from one parent in that sequence, selected by
complete identity. The child supplies and keeps its own identity while inheriting
the parent's other settings. Parents remain ordinary records subject to normal
validation.
Use the shared dependency ordering and preserve assembled sequence order.
Resolve enclosing relationships before nested destination-local relationships.
Mappings combine recursively; sequences use the inheriting collection's policy.
Whole-record upsert replaces records during combination; explicit inheritance
uses the records that remain. Inheritance remains built-in and creates no
acquired source.
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
original scope and continuing ownership decisions, with sequence combination
and identified-sequence inheritance defined by
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
ConfigKit must define supported YAML semantics explicitly rather than inherit
parser defaults. Processing order and diagnostics must be deterministic for the
@@ -185,13 +292,14 @@ configuration. Do not run application hooks on invalid typed configuration.
User-controlled precedence is application defaults, ordered underlays, composed
main input, selected overlay, then explicitly supplied CLI overrides.
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
Domain resolution follows these
inputs and must remain recorded, not become a hidden override channel.
This priority order is distinct from processing order: typed defaults are applied
at decoding, and application resolution follows CLI input under the lifecycle
rules above.
Preserve node-based composition until the effective input is ready for typed
decoding. Preserve presence and source information through processing. Ordinary Go zero
values must not be used to infer whether input was absent. Final configuration,
Preserve node-based composition through source assembly and inheritance until
typed decoding. Preserve presence and source information through later CLI and
application-resolution changes as well. Ordinary Go zero values must not be used
to infer whether input was absent. Final configuration,
metadata, and effective inspection must agree, including application resolution
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
Safe historical values and an explanation renderer are part of the implemented contract,
@@ -208,6 +316,72 @@ comparison and disclosure behavior, the [component overview](../internal/overvie
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
records the accepted decision.
## Collection Identity And Combination
ConfigKit owns a common collection policy for imported records, inline sequences,
selected overlays, and sibling inheritance. Configuration authors may declare
identity and combination rules; applications may register authoritative identity
constraints. Configuration declarations must agree with application constraints.
Keep source placement explicit and separate from policy, retaining the placement
decision in [ADR 0014](../adr/0014-sequence-placement-and-composition.md).
[ADR 0017](../adr/0017-collection-sequence-policy.md) owns the rationale for
collection policy and its relationship to application authority.
Gather declarations for the relevant destination and source or item-local scope
before strict composition. Applicable identity from accumulated lower tiers must
be available when composing higher-tier peers. Higher-tier declarations cannot
repair invalid lower-tier composition. Preserve the distinction between source
acquisition, dependency-ready scheduling within scopes, and precedence order.
Strictly compose each tier's contributions before applying one operation for the
collection against its lower value. Support whole-sequence replacement,
duplicate-rejecting append, and whole-record replacement by identity (`upsert`).
Append and upsert require identity; do not infer it from record fields or position.
Duplicate identities among peers fail under every mode. Source precedence and
inheritance use the same operations and deterministic ordering rules. Whole-record
upsert replaces the matching record's fields together; recursive record merging
and unkeyed sequence concatenation are outside this operation model.
An explicit mode persists at its applicable destination until a higher explicit
mode changes it. With no explicit mode, effective identity selects upsert and
its absence selects whole-sequence replacement. Inferred defaults do not become
persistent instructions. The inheriting destination chooses its operation; copying
a parent collection does not automatically copy its mode to that destination.
Track YAML identity and explicit modes according to their separate lifetimes.
Actual whole-value replacement discards lower YAML identity while preserving
application constraints and any new identity declaration. Item-local policies
belong to their records: retaining a record retains its rules, and discarding it
discards those rules. Policies independently applicable at a destination remain
in effect. Inheritance can copy YAML identity subject to agreement and replacement
rules; application registrations continue to constrain their registered paths.
Numeric positions alone do not establish policy ownership.
Allow declarations without accompanying records. A key declaration validates a
retained sequence immediately or remains pending in its owning scope when no
sequence exists. A mode-only declaration affects later combinations without
recombining earlier tiers. Neither declaration alone clears a collection, and
declaring replace alone does not authorize changing a retained collection's
identity. These rules do not independently impose path-existence or shape
requirements.
Enforce YAML identity during source composition, precedence, and identified
inheritance operations. Application registrations additionally constrain final
effective sequences after defaults, CLI overrides, and application resolution.
The application schema and validation own existence, nullability, and allowed
shape. The final identity guarantee covers actual sequences on successful load;
it does not cover every intermediate callback state or later caller mutations.
YAML-only declarations carry no such final guarantee through defaults, CLI
assignments, or application resolution.
Preserve source attribution, presence, history, sensitivity, and resource context
as records survive, move, or are replaced. Identity diagnostics use paths and
available origin information without exposing identity values. The guarantee
requires no new mutation-history system or exact reconstruction of causes for
records supplied by defaults or callbacks. Exact identity equality, declaration
syntax, presence cases, and operation tables belong in the
[configuration contract](../config.md#strict-document-composition).
## Ownership And Confidentiality
Returned configuration and metadata must not share mutable state across loads.
@@ -234,7 +408,7 @@ for safe messages supplied by their hooks.
Do not introduce automatic directory discovery, network providers, arbitrary
merge strategies, additional configuration languages, or application runtime
management without concrete consumer evidence and an explicit design decision.
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
owns the deferred-feature inventory. Significant boundary
changes require an ADR and corresponding policy/contract updates; implementation
convenience alone is not a reason to broaden the public API.
Record significant decisions to exclude capabilities, and their rationale, in
ADRs. Significant boundary changes require an ADR and corresponding
policy/contract updates; implementation convenience alone is not a reason to
broaden the public API.

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.11
**Last Updated**: 2026-09-28
**Last Updated**: 2026-10-16
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
@@ -38,9 +38,10 @@ roadmap owns implementation status and sequencing until the decision is
implemented. Current architecture, user, operator, integration, and internal
documentation are updated when the behavior lands.
`docs/roadmap/deferred.md` owns deliberate exclusions; task-specific plans
own execution order and progress. Completed plans retire during release
preparation.
`docs/roadmap/future.md` owns potential enhancements and their open questions;
task-specific plans own execution order and progress. Record significant
architectural exclusions and their rationale in ADRs. Do not maintain a roadmap
inventory of rejected proposals. Completed plans retire during release preparation.
### Audience And Detail
@@ -91,7 +92,7 @@ secret values.
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and conditions for reconsideration. | Task execution and current behavior. |
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
@@ -156,8 +157,10 @@ Use sequentially numbered ADR filenames such as
Treat the decision content of an accepted ADR as immutable. When a decision
changes, create a new ADR and update the earlier ADR's status to superseded.
Rejected architectural alternatives belong in the ADR; rejected product ideas
belong in the roadmap. Routine choices need no ADR.
Rejected architectural alternatives and significant architectural exclusions
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
ideas. Routine choices and rejected suggestions need no ADR unless they
represent a significant architectural decision.
## Maintenance

View File

@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version
```
The module-selected executable is ReleaseKit v0.3.0. 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.
`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

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.
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
validation toolchain. Retire completed planning and measurement records into
Git history while preserving [deferred work](../roadmap/deferred.md).
Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
## Compatibility and upgrade

22
docs/releases/v0.7.1.md Normal file
View File

@@ -0,0 +1,22 @@
# ConfigKit v0.7.1
This patch updates the maintainer release tool to ReleaseKit v0.4.1. ConfigKit's
runtime behavior and public API are unchanged.
## Compatibility and upgrade
No application-code or configuration migration is required from v0.7.0. The
consumer minimum remains Go 1.26.0, and the YAML dependency is unchanged.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.1
```
ReleaseKit v0.4.1 is a maintainer tool dependency and does not change consumer
configuration or runtime behavior. 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.7.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/docs/oss/attribution.md)

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

@@ -0,0 +1,70 @@
# ConfigKit v0.8.0
This minor release adds explicit input placement, relative import destinations,
shared source-version checking, and opt-in composition of identified sequence
records. Applications can assemble independently authored configuration fragments
into one final configuration while retaining source locations, provenance,
history, and sensitivity information.
## Changes
- Add `Destination` to ordinary load options and layered inputs. A source mapping
can contribute beneath literal application keys without being rewritten by the
consumer. Imports inherit that destination or append a relative YAML
`destination`. Physical import paths and resource contexts remain relative to
their declaring sources. See [input destinations](../config.md#input-destinations),
[import syntax](../config.md#explicit-imports), and the
[mounted collection example](../consumers/README.md#input-destinations-and-mounted-collections).
- Add `SourceVersionModeIfPresent` and `SourceVersionModeRequired` to apply one
application-owned version checker across main, underlay, imported, and selected
overlay documents. Enabled modes check source-local declarations and consume
accepted non-main headers before composition. Detached, sensitivity-aware
evidence is available through `SourceInfo.VersionCheck`. Acceptance remains
application policy; ConfigKit does not require declarations to equal the main
version. See [shared source-version checks](../config.md#shared-source-version-checks).
- Add explicit `PlacementModeSequenceItem` placement for ordinary and layered
inputs, and `placement: sequence-item` for path and glob imports. A source
mapping becomes one item at its destination; ordinary helper imports compose
within that item and can supply its identity. See
[imports](../config.md#explicit-imports).
- Add `Definition.SequenceCompositions` registrations for strict composition at
selected literal paths using one string identity field. Distinct records append
in contribution order, while duplicate identities conflict. Higher-priority
sequences still replace lower-priority sequences wholesale. Registration does
not add identity-based overlays or final identity validation after defaults and
application resolution. See the [composition contract](../config.md#strict-document-composition)
and [maintained prompt catalog](../consumers/README.md#identified-prompt-sequences).
- Extend source inspection with destination and placement evidence, including
whether a nested destination is unresolved in an intermediate or failed-load
snapshot. Preserve source-specific history, consumed-header sensitivity, and
explanations of descendants removed by sequence replacement as records are
assembled. Source locations continue to identify authored coordinates.
## Compatibility and upgrade
The new behavior is opt-in: zero-valued destinations retain root placement,
mapping placement remains the default, unregistered sequences retain strict
conflict behavior, and the default source-version mode preserves the existing
main-only version policy. Recursive mapping precedence, whole-sequence
replacement between tiers, and application-owned final validation remain intact.
Public option, definition, input, and source-information structs gain fields.
Consumers using unkeyed literals for affected structs must switch to keyed
literals or update them. `SourceInfo` now contains a `Path` and is no longer
comparable with `==` or usable directly as a map key; compare the relevant fields
or select an application-appropriate key instead. Existing keyed literals need
no changes to retain the previous loading behavior.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.8.0
```
The consumer minimum remains Go 1.26.0. The YAML dependency remains
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
macOS remains best-effort. Publication follows the existing
[release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/oss/attribution.md)

111
docs/releases/v0.9.0.md Normal file
View File

@@ -0,0 +1,111 @@
# ConfigKit v0.9.0
This minor release expands import selection, adds author-declared collection
identity and operations, and supports inheritance between identified records.
It also moves CLI assignments after inheritance and typed decoding. These changes
include breaking API and configuration behavior changes from v0.8.0; review the
migration guidance below before upgrading.
## Changes
- Extend imports with named-file and directory lists, directory filename patterns,
exclusions, recursion and depth controls, and explicit hidden-file and symlink
discovery controls. Add `ImportDefaults` to ordinary and layered options, with
authored declarations taking precedence over application defaults. Imports are
optional by default. `SourceInfo.ImportSelections` exposes completed optional
declarations that selected no sources. See the
[import contract](../config.md#explicit-imports) and
[consumer guidance](../consumers/README.md#import-defaults-and-selection-inspection).
- Use shared dependency-ready ordering for import composition and sibling
inheritance. Dependencies complete before dependents, with deterministic
selection among currently eligible contributions. Source acquisition and
callback order remain separate from assembly order. See the
[ordering decision](../adr/0015-shared-dependency-ready-ordering.md).
- Add root `configkit.sequences` declarations for collection identity and
`replace`, `append`, or `upsert` operations, including in selected overlays.
Strict peers combine distinct identities and reject duplicates; precedence
and inheritance apply the destination's collection operation. Upsert replaces
a whole matching record at its retained position. Identity supports compound
field sets and scalar strings, numbers, booleans, and null. See the
[collection contract](../config.md#strict-document-composition).
- Add `Definition.SequenceIdentities`, exact `SequenceIdentityAt` registrations,
and dynamic `SequenceIdentityMatching` selectors. Application registrations
govern structural collection operations and check existing matching final
typed sequences, including values from defaults, CLI assignments, and
application resolution. YAML-only declarations govern structural operations
without adding that final-result check.
- Support record inheritance within an identified sequence using an `extends`
mapping containing the parent's complete identity. The child retains its own
identity, relationships use surviving assembled records, and output sequence
order is preserved. See [sibling inheritance](../config.md#sibling-inheritance)
and the maintained [sequence catalog](../consumers/README.md#identified-prompt-sequences).
- Apply explicit CLI overrides to the decoded configuration before application
resolution. Mappings retain omitted fields and their presence metadata;
sequences replace whole lists independently of collection operations. See the
[CLI contract](../config.md#explicit-cli-overrides).
- Correct native import alias and contained-symlink handling, source-version
path projection, and provenance/history through collection movement,
replacement, inheritance, and CLI assignment. Reduce repeated scans,
accumulated policy copying, and sparse-projection storage during construction.
## Compatibility and upgrade
This pre-v1 minor release intentionally changes the v0.8.0 contracts:
1. **Migrate import requiredness.** The YAML `optional` field is removed and is
rejected. Replace `optional: true` with `required: false`; omission is equivalent
when the application does not default imports to required. Replace
`optional: false` with `required: true`. Imports that previously relied on
implicit requiredness now need `required: true`, or an application
`ImportDefaults.Required` default set to true. Review discovery filters:
`follow` and `hidden` default to true, and directory searches select all
filenames unless a pattern is supplied.
2. **Replace sequence registrations.** `SequenceComposition` and
`Definition.SequenceCompositions` are removed without compatibility aliases.
For a previous registration at `prompts` using `id`, use:
```go
rules.SequenceIdentities = []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
}
```
These registrations now also enforce identity in final typed sequences. Fix
missing or duplicate identities introduced by defaults, CLI assignments, or
application resolution. Registration does not require a collection to exist
or determine its allowed shape; schema and application validation retain
those responsibilities.
3. **Review collection precedence and ordering.** An identified collection now
defaults to upsert across layers and inheritance when no explicit mode
applies, rather than unconditional whole-sequence replacement. Declare
`mode: replace` at the relevant destination where replacement is intended.
With append or upsert, `[]` contributes no records and does not clear the
collection; null is invalid. Use `mode: replace` with `[]` to clear it.
An explicit mode persists at its destination until changed; a parent's mode
does not transfer to a different inheriting destination. Unidentified
sequences retain strict peer conflicts and whole-value precedence
replacement. Dependency-ready assembly can change sequence positions and
diagnostic or history order compared with the former traversal order.
4. **Review CLI recovery and inheritance assumptions.** CLI overrides can no
longer repair values that fail earlier decoding, bypass source or inheritance
errors, or add or cancel inheritance relationships. Supply structural
directives in source configuration. Application resolution still receives
explicit CLI values and may record subsequent normalization or derivation.
5. **Update affected Go literals and diagnostic handling.** Use keyed literals
for expanded option and source-information structs. Collection validation adds
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
(`sequence.conflict`); update diagnostic handling where applicable.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.9.0
```
The consumer minimum remains Go 1.26.0. The YAML dependency remains
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
macOS remains best-effort. Publication follows the existing
[release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/docs/oss/attribution.md)

949
docs/roadmap/audit.md Normal file
View File

@@ -0,0 +1,949 @@
# Consolidated Audit Review
Status: scope settled; 29 entries selected for implementation planning.
Two refactors are deferred to `future.md`. Implementation has not begun.
Consolidated: 2026-10-25. Baseline: `ecd5d5d`.
This document consolidates nine sequential audits for maintainer review. It owns
their open findings, supporting evidence, proposed remedies, and relationships.
The settled decisions select the [remediation roadmap's](roadmap.md) scope.
That document owns the combined target state and high-level dependency order;
this review owns detailed finding evidence and acceptance cases. Neither is a
staged implementation plan or a new set of release gates. The
[configuration contract](../config.md), public Go declarations, and
[architecture policy](../policy/architecture.md) continue to
own behavior and guarantees.
There are nine distinct reported defects: four from correctness, three from
resources, one from error handling, and one from security. Concurrency adds
evidence for an existing callback-lifetime defect. Other entries are cleanup,
efficiency, maintainability, or test improvements. Regression recommendations
are attached to the defects they protect rather than counted as additional
product fixes.
The 35 original numbered findings map to 31 review entries after deduplication
and integration of related test work. All findings remain unresolved; the
[settled scope decisions](#settled-scope-decisions) identify the 29 selected
entries. C03 and F07 have moved to `future.md`, with reference entries retained
here for traceability. All three scope questions are settled.
Their medium/low priorities retain the original assessments; those labels do not
imply severity certification or implementation order. IDs remain stable as work
is selected or completed.
## Evidence And Provenance
All nine audits were dated 2026-10-25. Their sequential commits changed only the
audit documents, so their production baseline is the same. This consolidation
does not independently reproduce the findings or claim new runtime evidence.
| Original report | Reviewed revision | Evidence contributed |
| --- | --- | --- |
| `correctness.md` | `47944d2` | Source review and standalone public-API reproductions of four defects. |
| `efficiency.md` | `5098b56` | Source-derived scaling cases and proposed algorithm changes; no benchmarks. |
| `complexity.md` | `320d0c5` | Responsibility/caller review, graph complexity estimates, and maintainability candidates. |
| `concurrency.md` | `a802734` | Ownership review, race checks, and corroboration of callback lifetime after panic. |
| `resources.md` | `0cca633` | Bounded allocation, retained-node, and host-handle diagnostics. |
| `error-handling.md` | `5925066` | Directory read/close fault injection through the public API. |
| `security.md` | `b30c9f1` | Trust-boundary review, bounded symlink operation counts, and a historical dependency spot-check. |
| `testing.md` | `eb5a82f` | Aggregate statement coverage, assertion review, and regression/test-cleanup recommendations. |
| `deadcode.md` | `b1c278b` | Type-aware reference inventory, graph/caller checks, and source review of private cleanup candidates. |
The full original reports, including exact probes, commands, source line
references, and validation limitations, are preserved at `ecd5d5d`. For example:
```sh
git show ecd5d5d:docs/roadmap/correctness.md
git show ecd5d5d:docs/roadmap/security.md
```
The [original-finding map](#original-finding-map) accounts for every numbered
finding, including duplicates and test recommendations integrated elsewhere.
Source links below identify implementation owners; historical line numbers are
left in the originals because later edits will move them.
### Recorded Validation And Its Limits
The audits report successful development checks, ordinary tests, full race tests,
`go vet`, and focused suites as specified in their individual records, using
Go 1.26.10 on Linux amd64. Concurrency additionally repeated six relevant engine
tests with race instrumentation, count 20, and CPUs 1, 4, and 8. Testing ran the
suite with shuffle enabled three times. These results did not detect the newly
reported defects and are not evidence that those defects have been fixed.
Testing recorded 771 ordinary test functions, three fuzz targets, and 29 executable
examples. Aggregate cross-package coverage was 90.3%: root 81.3%, engine 91.0%,
document 100%, YAML 89.9%, and maintained consumers 79.2%. Those figures locate
gaps; they are not targets or a measure of assertion strength. Only checked-in
fuzz seeds ran, not an extended campaign or mutation analysis.
Standalone diagnostics stayed outside the repository. Some potentially fatal
cases used disposable processes, small limits, or operation ceilings; larger
scaling examples were derived rather than executed. The dead-code inventory
considered Linux, Darwin, and Windows build configurations, but no audit reports
native macOS or Windows execution. Graph scores and reachability results were
triage aids, not proof that a function is unnecessary or a numerical refactoring
threshold. Cached analysis tooling was used outside the repository; no new
project tools or dependencies were introduced.
The security report's module-path advisory spot-check and successful module
integrity verification are historical evidence only. It recorded no listed
advisories for the selected YAML v4, ReleaseKit, or Goldmark v2 module paths, and
recorded the selected `x/mod` version as fixed for the two reports it found. It
did not perform complete toolchain/transitive reachability analysis or supply-
chain attestation; dedicated vulnerability, security, and secret scanners were
unavailable. This consolidation neither refreshes nor broadens that conclusion.
## Review Priorities And Dependencies
Recommended review order is reported defects with their regression coverage,
bounded cleanup, targeted efficiency changes, and then broader structural
refactoring. Small prerequisite cleanups can accompany a defect fix. The order
of the groups below is for navigation, not an execution sequence.
| Relationship | Consequence for scope selection |
| --- | --- |
| A02 and A05 | Make descriptor construction safe before reusing successful dynamic-type eligibility results. |
| B01 and B02 | Panic cleanup must close callback state and the owned host root independently. Neither fix substitutes for the other. |
| C01 and its filesystem tests | Strengthen error/handle assertions while adding failure-phase regressions, using one shared test owner. |
| C02 and deferred C03 | Complete link expansion remediation independently; reconsider glob readability extraction afterward through `future.md`. |
| D01, D02, and D03 | Remove the confirmed unused copies, then account for the complete retained graph. Removing one unused tree does not fix the remaining omissions. |
| E01, E02, and E03 | Address policy-state costs and responsibility boundaries together, preserving separate scaling and behavioral acceptance cases. |
| F01 and F02 | Removing the test-only acquisition mode does not by itself simplify active lifecycle handoffs. |
| F04, F09, and F12 | Replace useful facade ownership/query coverage before deleting disconnected or representation-coupled scaffolding. |
| F05 and deferred F07 | Remove unused placement bridges in this scope; reconsider wider transport simplification afterward through `future.md`. |
## A. Schema And Typed Values
### A01. Terminate Pointer-Only Recursive Descriptor Traversal
Type: reported defect. Priority: medium. Source: correctness 1; testing 1.
**Problem and evidence.** In [schema.go](../../internal/engine/schema.go), a legal
type such as `type Loop *Loop` produces a pointer descriptor whose element is
itself. [decode.go](../../internal/engine/decode.go) unwraps that descriptor
without consuming a non-null input node; `resolveSchemaPath` likewise assumes
pointer traversal eventually reaches another kind. A public load with a `Loop`
field and `value: {}` overflowed the stack in a process with a small stack limit.
`value: null` succeeded, but resolving a normalization path for that field hung
until the diagnostic process was stopped. The [schema
contract](../config.md#application-schema-binding) permits recursive types with
acyclic values; the inline-field restriction is not a blanket pointer restriction.
**Recommendation and acceptance.** Detect repeated descriptors while unwrapping
pointer-only chains in decoding and schema-path resolution, and review analogous
helpers. Incompatible non-null input must fail safely; path traversal must
terminate and reject unaddressable descendants. Preserve supported nil/null
values and ordinary recursive structs. Do not add a blanket schema rejection or
arbitrary depth cap. Add bounded regression coverage for both decoding and helper
path resolution, keeping fatal/hanging probes out of the default test process.
### A02. Make Failed Dynamic Descriptor Construction Transactional
Type: reported defect. Priority: medium. Source: correctness 2; testing 1.
**Problem and evidence.** `dynamicDescriptor` in
[value_operation.go](../../internal/engine/value_operation.go) passes the load's
descriptor cache to `schemaBuilder`. Failed construction leaves cached container
placeholders. For a dynamic struct containing an unsupported channel field, two
`Normalize` calls in one resolver, first with a typed nil pointer and then a
non-nil pointer to that type, reproduced a nil-pointer panic on the second call.
The first call reported a diagnostic but left a reusable partial descriptor.
The application callback itself did not panic.
**Recommendation and acceptance.** Stage new descriptors and commit only on
successful construction, or roll back all additions from a failed build. Include
recursive references among newly built descriptors; clearing only the immediate
failed container or adding a projection nil guard is insufficient. Preserve
previously valid descriptors, load-local ownership, and safe path-specific
diagnostics under [typed projection](../config.md#typed-projection-and-ownership)
and [resolution](../config.md#recorded-application-resolution). Test repeated
operations after a failed build in the same load. Coordinate A05 only after this
failure-state boundary is sound.
### A03. Distinguish Acyclic Slice Views In Cycle Detection
Type: reported defect. Priority: medium. Source: correctness 3; testing 1.
**Problem and evidence.** `valueVisit` / `visitFor` in
[value_operation.go](../../internal/engine/value_operation.go) identify slices by
type, kind, and starting pointer, omitting length. The finite value built by
`shared := []any{"leaf", nil}; shared[1] = shared[:1]` was rejected through mapping
sources, typed defaults, and CLI overrides, while an equivalent independently
allocated value succeeded. The nested view contains only the leaf, not its
enclosing view. The same helper serves projection/cloning and
[value.go](../../internal/engine/value.go).
**Recommendation and acceptance.** Include visible slice length in active-visit
identity, or use an equivalent distinction. Preserve rejection of actual self
and mutual cycles, nil/empty behavior, and detached output; preserving alias
identity is not required. Regressions should cover the shared source, override,
projection, and cloning paths under the existing supported-acyclic-value contract,
alongside true-cycle rejection.
### A04. Enforce Existing Node Budgets Before Container Reservations
Type: reported defect. Priority: medium. Source: resources 1; testing 1.
**Problem and evidence.** `projectSequence` and `projectMap` in
[projection.go](../../internal/engine/projection.go) reserve space for the full
container, and maps collect all keys, before child charging in
[value_operation.go](../../internal/engine/value_operation.go). Defaults containing
a maximum-length slice of zero-sized structs caused a `makeslice: cap out of range`
panic instead of `schema.projection_limit`; that diagnostic did not allocate a
huge input backing array. Less extreme cases can allocate excessive temporary
memory before rejection. Application resolution can reach these paths too.
**Recommendation and acceptance.** Check the known minimum node cost against the
remaining existing allowance with overflow-safe arithmetic, or bound initial
capacity and grow as charging succeeds. Review map reservation/key collection as
well as sequences. Preserve node/depth semantics, inline handling, and useful
diagnostic paths. Use bounded regressions, including small controlled allowances,
without enormous allocations. This enforces an existing budget, not a new length
cap or universal heap-size guarantee.
### A05. Reuse Successful Dynamic-Type Eligibility Checks Locally
Type: optimization candidate. Priority: low. Source: efficiency 6.
**Problem and evidence.** Each dynamic value in projection/cloning invokes
`descriptorContainsSecret` with a new visited map, even when its descriptor is
cached. N values of one concrete type with descriptor-graph size S therefore cost
O(N × S) eligibility work. N interface values containing nil pointers to one wide
supported struct can produce only N null values while repeating the full type
walk. This is a source-derived bound, not measured runtime.
**Recommendation and acceptance.** After A02, consider remembering successfully
approved concrete descriptors within `valueOperation`, reducing repeated-type
eligibility work to O(S + N). Cache only completed successful eligibility checks,
not traversal-local visits, partial descriptors, or path-bearing failures.
Preserve rejection of dynamic secret-reference types, including nil pointers and
recursive descriptor cases. This does not select cross-load schema caching or a
clone traversal redesign.
## B. Callback And Filesystem Lifetime
### B01. Deactivate Callback Contexts During Panic Unwinding
Type: reported defect. Priority: low. Sources: correctness 4, concurrency 1;
testing 1; corroborated by error-handling and security reviews.
**Problem and evidence.** `runResolution` in
[resolution.go](../../internal/engine/resolution.go) and `runValidation` /
`reportVersion` in [validation.go](../../internal/engine/validation.go) deactivate
contexts only after normal callback return. Public probes retained the context,
panicked, recovered outside `Load`, and then successfully queried resolution or
wrote validation/version diagnostics. The concurrency audit reproduced this
with race instrumentation without reporting a data race. The defect violates
callback lifetime, not a newly supported concurrent-use scenario.
**Recommendation and acceptance.** Defer deactivation within each callback
invocation, closing immediately at normal return and on unwinding, before later
projection or metadata work. Preserve the original panic and prohibit retained
context use through the established closed-context behavior. Test all three
callback kinds with external recovery. No recovery service, lock, atomic flag,
or broader callback thread-safety guarantee is needed. B02 separately owns root
handle cleanup.
### B02. Close The Owned Import Root When A Version Callback Panics
Type: reported defect. Priority: medium. Source: resources 3; testing 1.
**Problem and evidence.** Host import acquisition in
[import_acquisition.go](../../internal/engine/import_acquisition.go) opens an
`os.Root` through [filesystem_access.go](../../internal/engine/filesystem_access.go)
but relies on explicit ordinary-path closes. An imported version-check panic
bypasses them. A disposable Linux probe disabled GC, recovered outside `Load`,
and observed one remaining root descriptor through `/proc/self/fd`; the normal
control returned to zero. The imported file's own deferred close still ran.
**Recommendation and acceptance.** Install deferred fallback cleanup at the
owning boundary, preserving explicit normal close timing and diagnostics, as
selected-overlay acquisition already does. Avoid double-close confusion, leave
borrowed filesystems untouched, and propagate the original panic. A regression
must observe the owned host root, not merely a native file-close counter, and
document platform prerequisites. Context deactivation in B01 is a separate
requirement. This restores load-scope cleanup without adding panic recovery.
## C. Import Selection And Link Resolution
### C01. Preserve Directory Failure Phase And Strengthen Its Tests
Type: reported defect with test improvements. Priority: medium.
Sources: error-handling 1, testing 3.
**Problem and evidence.** `filesystemAccess.readDirectory` in
[filesystem_access.go](../../internal/engine/filesystem_access.go) returns open,
read, and close errors through one unqualified error channel.
[import_selection.go](../../internal/engine/import_selection.go) and
[glob.go](../../internal/engine/glob.go) treat any `fs.ErrNotExist` as absence.
A borrowed filesystem probe selected a failing directory plus a good directory,
using both `dir` and `glob` with `required: true`. Injecting `fs.ErrNotExist`
alongside entries from `ReadDir`, or from `Close`, let loads succeed with the bad
directory's contribution omitted. Permission-classified controls failed. All
eight selector/phase/error cases ran. This violates the existing distinction
between optional absence and failed acquisition.
**Recommendation and acceptance.** Preserve missing-on-open versus post-open
failure, for example with a small private failure record analogous to regular
file acquisition. Keep genuine missing-target rules, entry budgets, first-error
precedence, borrowed filesystem ownership, and safe existing traversal diagnostics.
Review both batched `fs.ReadDirFile` and standard fallback paths. A successful
second directory must not hide enumeration/close failure; raw backing error text
must not leak. No retries or filesystem transaction are proposed.
**Test improvements.** The shared fixture in
[native_filesystem_test.go](../../internal/engine/native_filesystem_test.go) and
[resource_context_test.go](../../internal/engine/resource_context_test.go) currently
checks aggregate closes and often only non-nil errors. One leaked handle and
one double-close can cancel out. Its canary check examines the terse error
summary, not structured diagnostics. Track per-handle closure, assert nil result,
diagnostic code and source, and inspect diagnostic messages/rendering for canaries.
Add entries/data-plus-error and close-phase cases through narrow failure seams.
Preserve intentionally ignored read-only regular-file close errors; this does
not impose a universal close-error policy.
### C02. Avoid Reexpanding Completed Finite Symlink Dependencies
Type: reported availability defect. Priority: medium. Source: security 1;
testing 1.
**Problem and evidence.** Host/native resolvers in
[filesystem_links.go](../../internal/engine/filesystem_links.go) track active links
but reuse no completed resolution. A finite contained graph `s00 -> .`,
`s01 -> s00/s00`, `s02 -> s01/s01`, and so on repeats completed dependencies.
Its work follows T(N) = 2T(N-1) + O(1), despite linear graph size. Directory
selection resolves discovered links before deciding on recursive descent, so
`recursive: false` does not avoid it. Existing source/entry/node/import-depth
budgets do not account for the repeated expansion.
A real-link fixture exposed through a native `fs.ReadLinkFS` wrapper recorded
32, 128, 512, 2,048, and 8,192 `Lstat` calls for 3, 5, 7, 9, and 11 entries;
corresponding `ReadLink` counts were 11, 57, 247, 1,013, and 4,083. Thirteen
entries reached a diagnostic ceiling of 12,000 `Lstat` calls. With following
disabled, that fixture used 17 `Lstat` calls and no `ReadLink`. The host resolver's
matching logic was reviewed, not separately operation-count tested.
**Recommendation and acceptance.** Reuse fully resolved targets within the
resolution operation, keyed for the rooted namespace and full link path, or use
equivalent dependency evaluation. Retain active-path cycle detection, containment,
finite reuse, relative/absolute host semantics, and the stable-input assumption.
A global visited-means-cycle rule would incorrectly reject valid inputs. Cover
both resolvers and directory/glob entry points with small fixtures and bounded
operation observation, not timing thresholds or exhaustion tests. No arbitrary
link cap, public cache, or default-follow change is selected.
The availability risk requires influence over imported filesystem data; it is
not an anonymous remote vulnerability by itself. No concurrent mutation or
outside-root target is needed, and accidental graphs can cause the same work.
### C03. Separate Glob Entry Preparation From Traversal
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
belong to [future work](future.md#1-separate-glob-entry-preparation-from-traversal),
for consideration after this roadmap's selected work is complete.
## D. Retained History And Unused Evidence
### D01. Account For The Complete Published History Graph
Type: reported defect. Priority: medium. Source: resources 2; testing 1.
**Problem and evidence.** The checkpoint in [load.go](../../internal/engine/load.go)
and [history.go](../../internal/engine/history.go) counts a different root set
from [load_result.go](../../internal/engine/load_result.go) publication. Separately
cloned post-CLI input, `structuralInput`, `preCLI`, and later inheritance copies
are not all represented. A read-only diagnostic of a small successful load with
a no-op resolver counted 14 distinct nodes before including the two published
replay roots and 22 with them. Source inspection found that the checkpoint uses
a different, same-sized decoded-final instance and also counts 14 in this case.
Generalized to N scalar items, the reported counts were 3N + 8 versus 5N + 12;
800,000 items would pass the checkpoint yet exceed the existing 4,000,000-node
history allowance. That large case was derived, not allocated or executed.
**Recommendation and acceptance.** Use pointer-aware accounting for every document
root actually retained at publication, including replay and copied inheritance
history, counting shared nodes once. Where practical, reserve/charge copies before
allocation. Coordinate removal of unused trees under D03 and recalculate the
retained set afterward; that removal alone does not establish complete accounting.
Reuse already-owned immutable snapshots only where equivalence and ownership
are demonstrated. This need not redesign public metadata copying.
Use a small controlled allowance to test publication integration and the existing
`explain.limit_exceeded` outcome, as well as count-once sharing. Helper-only tests
cannot detect roots omitted by callers. Preserve [historical
inspection](../config.md#historical-value-explanation), detached ownership, and
the existing quota; no smaller limit or universal heap-size bound is proposed.
### D02. Remove The Unconsumed Override-Result Pre-CLI Copy
Type: cleanup. Priority: medium. Source: deadcode 1.
**Problem and evidence.** `decodedOverrideResult.preCLI` in
[override_decode.go](../../internal/engine/override_decode.go) clones the entire
decoded final tree even without CLI assignments. Its readers are private tests;
production independently clones `before.final` into the active context in
[override_context.go](../../internal/engine/override_context.go).
**Recommendation and acceptance.** Remove that result member, its clone, and
assertions solely about it. Preserve the independently owned pre-CLI context
that actually feeds resolution, publication, and explanation. Retain behavioral
CLI/history/ownership coverage. The change removes a redundant traversal, not
the active replay snapshot or its detachment boundary.
### D03. Remove The Unqueried Published Structural-Input Copy
Type: cleanup. Priority: medium. Source: deadcode 2.
**Problem and evidence.** `Inspection.structuralInput` in
[load.go](../../internal/engine/load.go) is cloned and indexed at publication,
but has no production reader. [explain.go](../../internal/engine/explain.go)
uses source/inheritance replay, `preCLI`, and `input`. The immutable lookup is
addressed by supplied node pointers; indexing this otherwise unused root does
not make it useful and keeps its mappings alive.
**Recommendation and acceptance.** Remove the inspection member, final clone,
and its `retainedTrees` inclusion. Keep earlier `resolved.structuralInput` uses
for path finalization and sensitivity. Check explanation, historical paths,
redaction, and ownership through active outputs. Coordinate D01 for all remaining
retained roots; do not infer that structural evidence is globally unused.
### D04. Stop Constructing Unconsumed Combination Evidence
Type: cleanup. Priority: medium. Source: deadcode 5.
**Problem and evidence.** `sequenceCombinationResult.discardedLower` in
[sequence_combine.go](../../internal/engine/sequence_combine.go) allocates a list
of all replaced lower indexes with no production reader. Three tests inspect it.
`inheritanceReplacement.path`, `previous`, and `effective` in
[inheritance.go](../../internal/engine/inheritance.go) are also unread, but their
writers perform path/origin copies and displaced-subtree walks. Active inheritance
history uses operations and projected changes elsewhere. Replacement `before`
and `after` trees still feed node accounting.
**Recommendation and acceptance.** Remove only unread members and work solely
feeding them. Keep replacement trees, item projections, replacement mappings,
`appendedHigher`, active provenance/history, and node accounting. Keep
`sequenceIndexes` for append and `displacedOrigins` for overlay callers. Replace
private unused-evidence assertions with existing behavior-level replacement,
removed-descendant, and history protection where necessary. Coordinate D01 and
E03 without deleting active evidence merely because another representation exists.
## E. Collection Processing And Efficiency
All complexity estimates here describe identified internal work with bounded-
depth examples and expected hash lookup costs. Actual path processing, copied
payload, and expanded output remain costs; no end-to-end complexity or timing
guarantee is proposed.
### E01. Avoid Scanning Every Policy For Each Replacement
Type: optimization candidate. Priority: medium. Source: efficiency 1.
**Problem and evidence.** `retireCollectionPoliciesAtOrBelow` in
[collection_policy_state.go](../../internal/engine/collection_policy_state.go)
scans all P policies for each of Q ordinary replacements in
[overlay.go](../../internal/engine/overlay.go), even if the changed scalars are
unrelated to every collection. P = Q = N gives O(N²) visits for linear-sized
input and output within one tier.
**Recommendation and acceptance.** Consider an operation-local semantic-path
index with descendant queries, maintained as policies change, or batch retirement
where timing remains equivalent. The disjoint case can approach O(P + Q), with
additional work charged to affected entries/path lengths. Preserve explicit-mode
persistence, record ownership, and the moment old identity retires; delayed
retirement must not clear newly established higher-tier policy. Evaluate existing
inheritance indexing without requiring identical interfaces. Retain a distinct
disjoint-replacement case even if implementation is coordinated with E02/E03.
### E02. Avoid Recopying Unrelated Policy Across Small Tiers
Type: optimization candidate. Priority: medium. Source: efficiency 2.
**Problem and evidence.** `lowerCollectionPoliciesForClaims` in
[collection_retirement.go](../../internal/engine/collection_retirement.go) clones
and scans lower policy, and `resolveStrictCollectionPolicies` in
[compose.go](../../internal/engine/compose.go) clones it again. Overlay preparation
repeats full-state work and checks retained destinations. P initial policies
followed by T scalar-only tiers cost O(T × P) work/allocation although only O(P + T)
material exists. Copies include record order and nested record policy; these
are transient private copies, not independently published snapshots.
**Recommendation and acceptance.** Separate read-only lower-policy lookup from
owned current-tier changes, localize work to incoming claims/declarations, and
apply changes to the assembly owner. Keep copies at actual mutable ownership
boundaries. Removing the immediate duplicate copy helps but does not alone fix
O(T × P) growth. Preserve pending declarations, replacement eligibility, and
independent nested record policies. This case remains distinct from E01 and the
deferred public metadata-copy experiment.
### E03. Clarify Policy Preparation And Merge Evidence Application
Type: selected refactor. Priority: medium. Source: complexity 3.
**Problem and evidence.** [overlay.go](../../internal/engine/overlay.go) combines
claim/declaration preparation, operation resolution, callbacks, diagnostics,
origins/history, retirement, record policy rebuilding, and declaration-only
finalization. Overlay and [inheritance](../../internal/engine/inheritance_collection.go)
separately translate sequence results into history descriptors and reconstruct
record order. `combineSequence` already owns shared value combination.
**Recommendation and acceptance.** Consider explicit preparation, application,
and finalization operations with a small result/owner for evidence and diagnostics.
Share straightforward history-descriptor construction; assess projected-record
selection sharing while keeping precedence and inheritance policy ownership
explicit. Do not unify distinct origin or mode-lifetime rules behind a generic
strategy framework. Coordinate E01/E02 so extraction does not preserve avoidable
full-table work. Preserve pending declarations, replacement ordering, retained
versus incoming record owners, and stable history. D04 can remove unused evidence
without requiring this broader refactor.
### E04. Index Tier-History Projection Lookups
Type: optimization candidate. Priority: medium. Source: efficiency 3.
**Problem and evidence.** `projectTierHistoryApplications` in
[sequence_history_projection.go](../../internal/engine/sequence_history_projection.go)
calls unindexed `documentAtPath` for each mapped sequence. N sibling collections
selected by one wildcard identity registration incur O(N²) mapping comparisons
while projecting linear-sized source/history material.
**Recommendation and acceptance.** Lazily prepare one read-only `documentLookup`
per original application value with mapped contributions, or collect needed nodes
in one traversal. With V nodes, S contributions, depth D, and I projected items,
that portion can be O(V + S × D + I). Preserve authored coordinates, sparse item
projections, replacement evidence, and independent output storage. This is load-
time coordinate projection, not branch-local explanation replay.
### E05. Restore Compound Identity Without Repeated Mapping Scans
Type: optimization candidate. Priority: medium. Source: efficiency 4.
**Problem and evidence.** `preserveInheritanceIdentityFields` in
[inheritance_scope.go](../../internal/engine/inheritance_scope.go) searches child
and merged mappings for each identity field. K fields and C/M child/merged
entries cost O(K × (C + M)) comparisons plus required clones. A wide compound
identity can make this quadratic without expanding record output.
**Recommendation and acceptance.** Index merged positions and walk child entries
against an identity-field set, or use equivalent local mapping indexes, for
O(K + C + M) lookup work plus copies. Preserve child-owned identity values and
locations, mapping order, and detached nodes. Do not change identity equality or
parent selection. Removing the restoration step instead requires a separate
equivalence review; apparent duplication alone is insufficient.
## F. Remaining Maintenance And Test Improvements
### F01. Remove The Test-Only Eager Acquisition Mode
Type: cleanup. Priority: medium. Sources: complexity 1, deadcode 8.
**Problem and evidence.** Both production constructors of the request declared in
[acquisition.go](../../internal/engine/acquisition.go) set
`inputAcquisitionRequest.deferAssembly` to true. Only private
test helpers select the eager composition branch in
[import_acquisition.go](../../internal/engine/import_acquisition.go). Acquisition
maintains two return protocols and tests can bypass the production phase handoff.
**Recommendation and acceptance.** Return retained scopes consistently and remove
the unused production mode. Tests needing composition should invoke the active
composition phase through a small test-only helper; acquisition-only tests should
remain acquisition-only. Preserve closure, pending-item reservation, accounting,
diagnostics, and meaningful composition coverage. This does not retire public
loading workflows or imply that every nil-state/directive context is unused.
### F02. Clarify Active Acquisition And Coordinator Handoffs
Type: selected refactor. Priority: medium. Source: complexity 2.
**Problem and evidence.** `acquireInput` in
[acquisition.go](../../internal/engine/acquisition.go) combines discovery, parsing,
directive/version branches, placement/accounting, dispatch, and passive contexts.
Version branches repeat source-ID/failure/result handling, and layered processing
extracts and restores directives. [load.go](../../internal/engine/load.go) contains
a substantial ordinary-load context fallback; [load_request.go](../../internal/engine/load_request.go)
repeats tier declaration/application/projection handoffs. Parsed, local, composed,
inherited, and version-path representations are difficult to follow together.
**Recommendation and acceptance.** Consider cohesive parsed-input directive/version
processing and standalone placement/registration operations, plus explicit context,
structural-expansion, and repeated tier handoffs. Reuse existing state/results;
helpers that merely share the same mutable surrounding locals do not resolve
ownership. Preserve visible phase order, ordinary/layered overlay differences,
default/enabled version timing, warnings, exactly-once callbacks, and load-wide
versus tier-local state. F01 removes a different, inactive protocol; neither task
requires a new context hierarchy or public request model.
### F03. Retire The Parallel Test-Only Import Directive Parser
Type: cleanup. Priority: low. Source: deadcode 7.
**Problem and evidence.** `parseImportSpecificationDirective` in
[import_spec.go](../../internal/engine/import_spec.go) is called only by the
`parseImportSpecsYAML` test fixture. It duplicates mapping/field dispatch already
owned by `parseRootDirectiveBlock` in [directives.go](../../internal/engine/directives.go).
**Recommendation and acceptance.** Use the active parser with the appropriate
context, or parse the declaration list directly when that is the tested boundary,
then remove the obsolete implementation. Preserve malformed-declaration and
selector coverage. Keep the active imports-only context used in layered version
processing; thin test helpers delegating to production are not inherently dead.
### F04. Remove The Disconnected Root Record-Clone Family
Type: cleanup. Priority: low. Source: deadcode 3; related testing 6.
**Problem and evidence.** The private family in root [records.go](../../records.go)
has no production facade entry: `cloneLocation`, `cloneLocations`, `cloneSourceInfo`,
`cloneImportSelections`, `cloneSourceVersionCheck`, `cloneSourceInfos`, `cloneOrigin`,
`cloneFieldInfo`, `cloneChange`, `cloneDiagnostic`, and `cloneDiagnostics`.
Helpers refer to each other; only tests call `cloneSourceInfo`. Active root-owned
records come through [conversion.go](../../conversion.go).
**Recommendation and acceptance.** Delete the disconnected family and tests solely
protecting it. Keep conversion assertions and useful returned-record ownership
coverage through repeated public queries and mutation of mutable nested results.
Replacing an entire `Destination` field on a copied struct does not demonstrate
nested detachment. Keep active engine clones and `Path.clone`; do not expose
opaque path internals or invent a stronger copy guarantee. Coordinate F12.
### F05. Remove Uncalled Engine Helpers
Type: cleanup. Priority: low. Source: deadcode 4; placement subset also complexity 5.
The type-aware audit found no callers or function-value uses in the inspected
production/test build configurations for the following helpers:
| Candidate | Active counterpart to preserve |
| --- | --- |
| `mergeDisjointMappings` in [compose.go](../../internal/engine/compose.go) | `mergeDisjointMappingsAtPath` and shared recursive merging; the old helper references only itself. |
| `mergeHistoryDocument` in [history.go](../../internal/engine/history.go) | `mergeHistoryDocumentAtPath` with sequence evidence. |
| `inheritanceDirectiveLocation` in [inheritance.go](../../internal/engine/inheritance.go) | Preparation's directive entry/location handling. |
| `addSecretEvidencePath` in [secret.go](../../internal/engine/secret.go) | Projected version sensitivity evidence and the active path accumulator. |
| `PlacementModeFromFacade`, `PlacementModeForFacade` in [destination.go](../../internal/engine/destination.go) | Input/options conversion and `SourceInfoPlacementModeForFacade`. |
**Recommendation and acceptance.** Remove these definitions and imports/comments
solely supporting them after confirming references at implementation time. Preserve
active counterparts and public root APIs. The placement pair can be removed
without selecting the broader transport work in F07.
### F06. Trim Unread Private Result And Transport Members
Type: cleanup. Priority: low. Source: deadcode 6.
The audit identified the following populated members without production readers.
Some private tests inspect them. Remove only these slots and computations solely
feeding them, preserving validation and active outputs:
| Owner and candidate members | Boundary to preserve |
| --- | --- |
| `collectionPolicyResolution.declarations`, `declarationsPresent`, `identityDeclarationPresent`, `applicationIdentityFields` in [collection_policy.go](../../internal/engine/collection_policy.go) | Local declaration normalization, `modeDeclarationPresent`, and `applicationIdentityPresent`. |
| `inputPresence.null` in [decode.go](../../internal/engine/decode.go) | Presence/location and effective-node null reporting. |
| `rootDirectiveExtraction.importsPresent`, `sequencesPresent` in [directives.go](../../internal/engine/directives.go) | Parsed declarations, diagnostics, and directive validation. |
| `importSelection.outcome` in [import_selection.go](../../internal/engine/import_selection.go) and glob selection | Candidate/admission behavior and the distinct, active public `importSelectionEvidence.outcome`. |
| `importDeclarationSpec.excludeSet` in [import_spec.go](../../internal/engine/import_spec.go) | Active `exclude` policy. |
| `schemaPath.field`, `dynamic` in [schema.go](../../internal/engine/schema.go) | Descriptor and success result used by path consumers; coordinate A01. |
| `selectedOverlayRequest.filesystem` in [selected_overlay.go](../../internal/engine/selected_overlay.go) | Explicit filesystem-access argument and borrowed ownership. |
| `sequenceDeclarationSpec.destinationLocation` in [sequence_declaration.go](../../internal/engine/sequence_declaration.go) | Other active declaration/field diagnostic locations. |
| `sourceVersionEvidence.absence`, `checked`; `sourceVersionAncestor.kind` in [source_version.go](../../internal/engine/source_version.go) | Actual checks, version/presence/location evidence, ancestor paths, and sensitivity. |
| `postDecodeOverrideContext.sources` in [override_context.go](../../internal/engine/override_context.go) | Local source list used to build callback resolution input. |
**Acceptance.** Preserve public presence, null, selection, version, diagnostic,
and ownership behavior. Rework assertions on inactive annotations only where
they protect a meaningful supported outcome. A lack of selector reads alone is
not a general removal rule: map keys, embedded records, and interfaces require
separate analysis.
### F07. Simplify Redundant Facade-To-Engine Transport
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
belong to [future work](future.md#2-simplify-redundant-facade-to-engine-transport),
for consideration after this roadmap's selected work is complete. F05's bounded
removal of unused placement bridges remains selected.
### F08. Index Secret-Failure Location Queries Lazily
Type: optimization candidate. Priority: low. Source: efficiency 5.
**Problem and evidence.** `secretDiagnostic` in
[secret_reference.go](../../internal/engine/secret_reference.go) calls unindexed
nearest-location traversal for each failed reference. N missing references in a
wide mapping incur O(N²) comparisons, even when they use one cached environment
name. Normal deterministic traversal/sorting does not require these scans.
**Recommendation and acceptance.** Consider a lazy read-only `documentLookup`
shared during secret resolution, or carry relevant location state through the
walk. With V nodes, F failures, and depth D, attribution can be O(V + F × D),
apart from sorting. Keep the successful path free of an unnecessary eager index.
Preserve all diagnostics, ancestor fallback, source coordinates, and safe messages;
failing fast would change behavior rather than optimize it.
### F09. Cover Public Historical-Path Enumeration
Type: test improvement. Priority: medium. Source: testing 2; related testing 6.
**Problem and evidence.** Root `Metadata.HistoricalPaths` in
[metadata.go](../../metadata.go) had no executed statements in the aggregate
profile. Engine tests cover it thoroughly, but not root path conversion. An empty
or incorrectly converted public result could leave engine tests green.
**Recommendation and acceptance.** Add one external-package test enumerating
effective and removed paths, using returned paths with `Lookup` and `History`,
including a numeric-key/sequence shape change. Cover sorted unique pointer
spelling, zero metadata, and replacement of a returned slice element without
affecting later queries. Keep detailed path mutation/shape tests in the engine.
This can replace F12's private segment-representation assertion without copying
the complete internal matrix or exposing private segments.
### F10. Strengthen Fuzz Detachment Assertions
Type: test improvement. Priority: low. Source: testing 4.
**Problem and evidence.** `FuzzCompositionAndOverlay` in
[composition_fuzz_test.go](../../internal/engine/composition_fuzz_test.go) appends
to the result root then compares inputs. A shallow root copy sharing existing
descendants can pass because append changes neither the input's slice header nor
elements within its old length. Existing deterministic merge tests already
exercise meaningful nested mutation.
**Recommendation and acceptance.** Mutate an existing result descendant or
in-length element within the bounded fuzz target, then compare both inputs.
Handle empty containers separately and retain input immutability/commutativity
properties. Improve the existing assertion rather than adding another ownership
matrix or claiming all ownership coverage is absent.
### F11. Replace Exact Stat-Call Choreography With Observable Ordering
Type: test cleanup. Priority: low. Source: testing 5.
**Problem and evidence.** `TestSelectNamedImportsChecksNormalizedPathsLexically`
in [import_selection_test.go](../../internal/engine/import_selection_test.go)
requires `a.yaml,a.yaml,z.yaml,z.yaml` stat calls but checks candidate count only.
Lexical handling matters; exactly two stats per file is not an established contract.
**Recommendation and acceptance.** Assert ordered candidate paths and, where
needed, the first diagnostic site using failing targets. Consolidate with existing
selection tests and delete the standalone case if its protection is subsumed.
Keep meaningful observations such as not opening excluded contents. Do not turn
incidental helper call counts into an efficiency or behavior guarantee.
### F12. Consolidate Facade Scaffolding Around Public Behavior
Type: test cleanup. Priority: low. Source: testing 6; related deadcode 3.
**Evidence and recommendations.**
- Remove the duplicate six-type ownership loop in
[facade_compatibility_test.go](../../facade_compatibility_test.go), retaining the
comprehensive owner in [facade_candidate_test.go](../../facade_candidate_test.go),
compile-time interfaces, public construction, and distinct lifecycle checks.
A literal assignment equality assertion on a new `Result.Config` adds no
protection beyond compilation and ordinary Go assignment.
- Replace the former `TestWrappedPathsRetainEngineSegmentIntent` internal
representation check with public contextual mapping/index behavior under F09.
Keep engine segment-intent coverage; public source-form behavior is consolidated
in [facade_equivalence_test.go](../../facade_equivalence_test.go).
- Move `TestWrappedSourcesPreserveEngineOwnership` from direct `engine.Load`
through `source.toEngine()` to public `Load` and root error assertions.
Consolidate reader cases with [facade_equivalence_test.go](../../facade_equivalence_test.go)
while preserving explicit copied-reader and byte-source mutation coverage.
- F04 owns removal of the disconnected clone family and the ineffective whole-
destination assignment assertion in [records_test.go](../../records_test.go).
Keep useful conversion and returned-record ownership protection there or in
existing public source-snapshot tests.
**Acceptance.** Remove scaffolding only after preserving its distinct behavioral
risks. Root integration tests still protect conversion, error typing, callbacks,
source identity, disclosure, and ownership; engine coverage does not substitute
for them. Static public/dependency-boundary tests remain useful.
## Boundaries To Preserve
The audits found the architecture coherent and did not establish a need for new
public features, a replacement loading API, package proliferation, or a general
visitor/strategy framework. Root-owned conversions, synchronous load-local state,
shared dependency scheduling, indexed combination, separated inheritance phases,
and passive resource contexts remain useful owners. The clone traversal was
already reviewed and retained at `34cbc0d:docs/roadmap/consolidation.md`; a high
complexity estimate does not reopen it.
No additional supported-use data race, deadlock, background-work leak, credential
disclosure, command injection, authority bypass, or host containment escape was
established. These are limited audit observations, not absence guarantees.
Caller-owned mutable objects, blocking operations, safe callback messages, and
borrowed filesystem behavior retain their documented responsibilities. No new
locking, cancellation, retry, authentication, permission, or environment-variable
restriction follows from these findings.
Preserve raw-error suppression, structured safe diagnostics, original application
panic propagation, intentional bootstrap partial writes, allowed partial writer
output, and documented read-only regular-file close handling. Regression tests
must enforce existing contracts rather than broaden them. Optional absence must
remain distinct from acquisition failure, and genuine finite reuse from cycles.
Public APIs are not dead merely because repository examples omit them. Interface
dispatch, reflection, build constraints, identity tokens, map-key equality, and
uninstantiated generics require more than caller counts. In particular,
`decodeInlineMapEntry` and import-cycle diagnostics remain active.
`Inspection.resolution` retains recorded snapshots under the existing resolution
contract even though replay uses a coalesced view; it is not a cleanup candidate.
Selected stable Kahn ordering, deterministic sorting, exact numeric identity,
and the expressly selected registration-overlap check are not reconsidered here.
Required detached copies and genuinely expanding output remain legitimate costs.
The broader replay, metadata-handoff, cross-load schema-cache, and contributor-
trace experiments stay in [future.md](future.md#5-performance-experiments).
Local fixes and removal of proven unused work do not select those experiments.
## Validation Approach For Selected Work
Use the existing [development guide](../development.md#baseline-validation) and
[testing policy](../policy/testing.md). Add meaningful regressions with the
selected fixes, at the narrowest stable boundary that detects the reported
failure. Extend existing owners rather than duplicating internal matrices at
every layer. Assert nil results and safe structured failures where relevant,
and verify retained state/handles independently when they have distinct owners.
Keep termination, allocation, and link-expansion probes bounded. Use small
controlled budgets, disposable processes where necessary, and filesystem
observation where it detects the specific defect. No wall-clock thresholds,
coverage percentages, complexity-score limits, exact incidental syscall traces,
new resource caps, or extra release gates are selected. Algorithmic evidence is
not a measured speedup. Performance experiments need separate scope and suitable
measurement conditions; no benchmarks were run for this consolidation.
Once selected work changes behavior or structure, update its canonical contract
or internal owner as appropriate. This review document remains the finding/status
owner until an implementation plan is prepared; completed records can retire
to Git history under the normal documentation lifecycle.
## Original-Finding Map
Original files are retrieved from `ecd5d5d:docs/roadmap/<filename>`. Every numbered
finding is mapped below; repeated citations are corroboration or shared coverage,
not extra fixes.
| Original finding | Consolidated owner |
| --- | --- |
| correctness 1 | [A01](#a01-terminate-pointer-only-recursive-descriptor-traversal) |
| correctness 2 | [A02](#a02-make-failed-dynamic-descriptor-construction-transactional) |
| correctness 3 | [A03](#a03-distinguish-acyclic-slice-views-in-cycle-detection) |
| correctness 4 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding) |
| efficiency 1 | [E01](#e01-avoid-scanning-every-policy-for-each-replacement) |
| efficiency 2 | [E02](#e02-avoid-recopying-unrelated-policy-across-small-tiers) |
| efficiency 3 | [E04](#e04-index-tier-history-projection-lookups) |
| efficiency 4 | [E05](#e05-restore-compound-identity-without-repeated-mapping-scans) |
| efficiency 5 | [F08](#f08-index-secret-failure-location-queries-lazily) |
| efficiency 6 | [A05](#a05-reuse-successful-dynamic-type-eligibility-checks-locally) |
| complexity 1 | [F01](#f01-remove-the-test-only-eager-acquisition-mode) |
| complexity 2 | [F02](#f02-clarify-active-acquisition-and-coordinator-handoffs) |
| complexity 3 | [E03](#e03-clarify-policy-preparation-and-merge-evidence-application) |
| complexity 4 | [C03](#c03-separate-glob-entry-preparation-from-traversal) |
| complexity 5 | [F07](#f07-simplify-redundant-facade-to-engine-transport), with bounded bridge removal in [F05](#f05-remove-uncalled-engine-helpers) |
| concurrency 1 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding), corroboration |
| resources 1 | [A04](#a04-enforce-existing-node-budgets-before-container-reservations) |
| resources 2 | [D01](#d01-account-for-the-complete-published-history-graph) |
| resources 3 | [B02](#b02-close-the-owned-import-root-when-a-version-callback-panics) |
| error-handling 1 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests) |
| security 1 | [C02](#c02-avoid-reexpanding-completed-finite-symlink-dependencies) |
| testing 1 | Regression acceptance under A01–A04, B01–B02, C02, and D01 |
| testing 2 | [F09](#f09-cover-public-historical-path-enumeration) |
| testing 3 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests), including shared fixture assertions |
| testing 4 | [F10](#f10-strengthen-fuzz-detachment-assertions) |
| testing 5 | [F11](#f11-replace-exact-stat-call-choreography-with-observable-ordering) |
| testing 6 | [F12](#f12-consolidate-facade-scaffolding-around-public-behavior), with F04 and F09 |
| deadcode 1 | [D02](#d02-remove-the-unconsumed-override-result-pre-cli-copy) |
| deadcode 2 | [D03](#d03-remove-the-unqueried-published-structural-input-copy) |
| deadcode 3 | [F04](#f04-remove-the-disconnected-root-record-clone-family) |
| deadcode 4 | [F05](#f05-remove-uncalled-engine-helpers) |
| deadcode 5 | [D04](#d04-stop-constructing-unconsumed-combination-evidence) |
| deadcode 6 | [F06](#f06-trim-unread-private-result-and-transport-members) |
| deadcode 7 | [F03](#f03-retire-the-parallel-test-only-import-directive-parser) |
| deadcode 8 | [F01](#f01-remove-the-test-only-eager-acquisition-mode), duplicate of complexity 1 |
## Settled Scope Decisions
### OQ-1. What Should The First Implementation Scope Include?
**Settled:** include all nine reported defects with their regression coverage,
the bounded cleanup, and the identified independent test improvements.
| Selected work | Finding IDs |
| --- | --- |
| Reported defects and accompanying regression coverage | A01–A04, B01–B02, C01–C02, D01 |
| Removal of unused copies, evidence, helpers, fields, and test-only production paths | D02–D04, F01, F03–F06 |
| Public coverage and stronger or less redundant test assertions | F09–F12 |
This selects 21 review entries. Reconfirm cleanup evidence on the implementation
baseline before removal, and preserve distinct behavioral protection when
replacing tests. Coordinate unused history copies with D01's retained-graph
accounting; verify callback-state closure and host-root closure independently.
Keep fixes and cleanup in focused, reviewable commits, even if delivered in one
sprint. Cleanup must not delay a consequential fix.
The combined scope restores existing contracts and removes demonstrated
maintenance burden while avoiding repeated work at the same ownership and test
boundaries. Delivering defects first and cleanup separately would reduce the
initial review surface, but would retain redundant work and revisit those
boundaries. The combined approach is selected.
Efficiency and broader structural choices are addressed separately below. This
decision does not select new public behavior or establish an implementation
sequence.
### OQ-2. Which Efficiency Candidates Should Accompany Remediation?
**Settled:** include the four localized efficiency improvements below. E01/E02
are addressed separately with collection-policy preparation and evidence
ownership (E03) under the settled OQ-3 decision.
| Selected improvement | Scope and dependency |
| --- | --- |
| E04: tier-history projection lookups | Reuse a local lookup index or equivalent traversal while preserving authored coordinates and projection evidence. |
| E05: compound identity restoration | Avoid repeated mapping scans while preserving child identity, mapping order, and detached nodes. |
| A05: dynamic-type eligibility reuse | Reuse only completed successful checks within the operation, after A02 repairs descriptor construction failure state. |
| F08: secret-failure location lookup | Include at lower priority; prepare lookup state lazily so successful loads avoid unnecessary indexing. |
Together with OQ-1, this selects 25 review entries. These local changes address
source-demonstrated repeated work without requiring public APIs, persistent
caches, or a new architectural model. Preserve their behavioral acceptance
cases and keep implementation proportionate. E01/E02 involve policy retirement
timing and mutable ownership, so their scope belongs with the E03 discussion.
Deferring all six until consumer profiling was considered. That would reduce
initial scope and avoid optimizing uncommon workloads, but would retain known
growth patterns with relatively direct remedies in the four selected cases.
The selected approach does not promise a measured startup improvement; practical
benefit depends on configuration shape and workload. Broader experiments in
[future.md](future.md#5-performance-experiments) remain deferred. C02's availability
fix and A04/D01's existing-budget enforcement remain selected independently.
### OQ-3. How Much Structural Refactoring Should Be Selected?
**Settled:** select E01, E02, E03, and F02. Defer C03 and F07 to the first two
entries of [future.md](future.md) for consideration after the rest of this roadmap
is complete. F01's removal of the test-only protocol remains selected under OQ-1.
| Selected work | Scope |
| --- | --- |
| E01/E02/E03: collection-policy retirement, copying, and evidence ownership | Address the related costs and preparation/application boundaries together, retaining distinct scaling and behavioral acceptance cases. |
| F02: active acquisition and coordinator handoffs | Extract cohesive repeated operations with clear inputs/results while preserving visible phase order and ordinary/layered differences. |
For collection policy, distinguish read-only existing state, policy affected by
incoming declarations or replacements, and the changes/evidence retained by the
assembly. Preserve retirement timing, newly established policy, explicit-mode
persistence, and nested record ownership. A smaller function or fewer copies
alone does not establish success. For acquisition, moving blocks into helpers
that continue sharing mutable surrounding state would not clarify the handoff;
keep the existing loading model and named responsibilities.
The selected combination offers a focused improvement in efficiency and
maintainability at active ownership boundaries. Including C03/F07 now would
broaden review for more modest readability benefits; neither is necessary for
the selected fixes. They remain plausible follow-up work, not rejected proposals.
Deferring all structural work would reduce immediate change scope but retain the
policy inefficiencies and difficult acquisition handoffs. No new public API,
generic framework, or configuration semantics are selected.
Together, OQ-1 through OQ-3 select 29 entries. All scope questions are settled;
the next step is to prepare the implementation plan with sequencing and focused
validation. No implementation has been performed by these planning decisions.

View File

@@ -1,57 +0,0 @@
# Deferred Scope
This document owns intentionally excluded features, not a committed delivery plan.
The [architecture policy](../policy/architecture.md) governs changes to these
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
and consumer adoption in the [consumer guide](../consumers/README.md).
## Explicit non-goals and deferred scope
General environment-variable configuration overlays are not supported. Typed secret
references and explicit directory bootstrap are the environment convention. Literal
credential configuration and implicit secret-directory discovery are unsupported.
The following remain deliberately excluded:
- automatic `conf.d` discovery;
- remote imports, arbitrary providers, and additional configuration formats;
- custom merge strategies, sequence merging, reset operators, multiple-parent
inheritance, and cross-scope references;
- named overlay inheritance;
- parser-specific or custom structural-decoding extension APIs;
- generic dynamic-subtree conversion and module-registry APIs;
- automatic schema migrations;
- hot reload, runtime resource replacement, and remote resource validation;
- a bundled CLI framework or application commands; and
- configuration fingerprints.
Applications may build their own validate, effective-value, source, bootstrap, and
explanation commands around the library. Broadening these boundaries requires
concrete consumer evidence, compatibility review, and an explicit design change.
Effective configuration diffs are implemented over frozen successful-load
inspections; their current comparison and disclosure contract is in the
[configuration contract](../config.md#effective-configuration-diffs), with
consumer workflows in the [adoption guide](../consumers/README.md#effective-configuration-diffs).
Fingerprints remain deferred.
Built-in sibling inheritance using `extends` is implemented as described in the
[configuration contract](../config.md#sibling-inheritance). It is distinct from
inheritance between selected overlay files, which remains excluded above.
## Deferred performance experiments
The completed performance audit left these internal optimization candidates for
separate investigation:
- branch-local explanation replay instead of the current whole-tree replay;
- redesigning the private metadata-copy handoff while preserving detached public
records;
- caching schema descriptors across loads; and
- indexing complex contributor-trace membership while preserving its equality
rules and compact linked traces.
These are not committed implementation work. Reconsider them with evidence that
the benefit justifies the ownership, retained-memory, or equivalence risks. The
completed audit and implementation evidence are preserved in Git history at
`8e91a2b` under `docs/roadmap/`.

93
docs/roadmap/future.md Normal file
View File

@@ -0,0 +1,93 @@
# Potential Future Work
This document lists potential enhancements and maintenance work for consideration
in future ConfigKit releases. Entries are not accepted contracts, implementation
plans, or release commitments. Adopting a candidate requires an explicit design
decision and a separate plan. Significant architectural exclusions and their rationale belong
in ADRs; this document does not maintain a list of rejected proposals.
## 1. Separate glob entry preparation from traversal
Implemented in the [internal-boundary roadmap](internal-boundaries.md#glob-entry-preparation) (C03).
## 2. Simplify redundant facade-to-engine transport
Implemented in the [internal-boundary roadmap](internal-boundaries.md#facade-to-engine-transport) (F07).
## 3. Recursive body merging for identified records
Status: deferred. The implemented
[collection policy](../config.md#strict-document-composition) uses `upsert` to
replace a complete matched record. Recursive merging of fields inside matching
records remains a separate candidate and would revisit the whole-record update
boundary selected by [ADR 0017](../adr/0017-collection-sequence-policy.md).
Consider this only in response to a concrete consumer need. A proposal would need
to define conflicts for overlapping body fields, removal of omitted fields,
presence and ordering, and how provenance, history, sensitivity, and resource
context follow partially retained records. Any change requires a new decision and
implementation plan; this entry does not alter current upsert behavior.
## 4. Required version declarations for selected sources
Status: deferred. PromptKit requested a way to require version declarations on
a selected subset of source files while permitting other fragments to omit them.
### Motivation and proposed behavior
PromptKit could require each collection's root file (for example,
`collection.yml` or `meta.yml`, with naming still undecided) to declare an
accepted specification version. Other files would use the existing
[check-when-present behavior](../config.md#shared-source-version-checks).
This would enforce a rule that otherwise remains a documentation convention.
All declarations would use the same application-owned `Definition.Version`
path and acceptance policy. The proposal concerns where declarations are
required; it does not introduce independently versioned collections or subtrees.
A root declaration would not prove that headerless descendants declare a
compatible version or replace final configuration validation.
### Questions to resolve before implementation
- **Selection API:** should callers mark explicit inputs, select sources by
path or predicate, or annotate imports? An input-level requirement is simpler,
but cannot by itself select a root reached only through an import.
- **Scope:** does a requirement apply only to the selected document or also to
its imports? Applying it only to that document best fits PromptKit's request;
imported fragments could still declare versions checked by the shared policy.
- **Mode interaction:** how should selection interact with disabled source
checks? Require an enabled mode, or activate checking for selected sources?
Selection would strengthen check-when-present behavior and add no requirement
beyond the load-wide required mode.
- **Source handling and evidence:** how should selection work across file,
reader, byte, and mapping inputs, glob matches, and repeated imports? Define
behavior for absent optional inputs and preserve source-local diagnostics,
inspection evidence, and existing sensitivity handling.
## 5. Performance experiments
Status: deferred pending evidence. The completed performance audit identified
four internal optimization candidates for separate investigation:
- Branch-local explanation replay instead of the current whole-tree replay.
- Redesigning the private metadata-copy handoff while preserving detached public
records.
- Caching schema descriptors across loads.
- Indexing complex contributor-trace membership while preserving its equality
rules and compact linked traces.
These are experiments, not committed implementation work or promised speedups.
Reconsider them with evidence that the benefit justifies the ownership,
retained-memory, or equivalence risks. The completed audit and implementation
evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
### Questions to resolve before implementation
- Which representative workloads show enough remaining cost to justify each
optimization, and what measurements would establish a useful improvement?
- Can narrower replay and membership indexes preserve explanation results,
ordering, provenance, and sensitivity behavior?
- Can metadata handoff changes preserve detached public records and ownership
across loads without introducing aliasing?
- What cache lifetime and concurrency design would make schema reuse worthwhile
without excessive retained memory?

View File

@@ -0,0 +1,296 @@
# Import Discovery And Facade Simplification Implementation Plan
Status: complete.
Baseline: `bb5540b62e382106d59678e2edb7655acfd5c960`.
Audience: a Luna coding agent implementing the stages sequentially.
## Scope And Execution
Implement **C03** and **F07** from the
[internal-boundary roadmap](internal-boundaries.md). That document owns the target
state and preservation requirements; this plan owns concrete private choices,
steps, validation, and progress. The preceding 29-finding remediation sprint is
complete. Its implementation record remains at
`bb5540b:docs/roadmap/implementation.md`; this plan does not reopen it.
Read `AGENTS.md`, [agent policy](../policy/agents.md), the [development
guide](../development.md), and applicable architecture, testing, and documentation
policies. Use graph-first discovery and verify actual callers before deleting
helpers. Preserve unrelated work.
This is one end-to-end sprint. Intermediate stages may fail to compile, especially
the coordinated engine/facade cutover in Stages 2–3. Do not create adapters,
aliases, duplicate paths, or extra abstractions solely to make intermediate
stages green. Run focused checks once their affected surface is buildable; record
pending checks and finish them before final acceptance. Commit focused nonempty
units under repository policy and record stage status, actual validation, and
known commit identifiers here. Completion requires the integrated result, not
just checked-off stages.
### Boundaries
- Preserve public root-owned declarations and signatures, YAML syntax, loading
semantics, ordinary/layered differences, callback order, diagnostics, source
identity, inspection, confidentiality, and ownership.
- Preserve glob literal-versus-discovered rules and directory/glob differences.
Keep completed-link reuse, directory failure phases, containment, and existing
accounting. This is not a discovery algorithm or permission-policy change.
- Keep `resolvedImportPolicy`, registration compilation, normalized request
responsibilities, and conversions with real ownership or domain meaning.
Simplification is limited to defaults transport and placement construction.
- Add no dependencies, caller controls, caches, limits, background work, release
gates, or performance promises. Do not publish a release, update consumers, or
revise accepted ADR decisions.
### Selected Ownership Model
Use the existing engine `ImportDefaults` name and its exported `Required`,
`Follow`, `Hidden`, `Recursive`, and `Pattern` fields as the sole engine
input-defaults type. Root `ImportDefaults` stays separately defined. Root
conversion maps the fields without cloning them; the public loading wrappers
immediately enter engine preflight. `loadSettings` takes the per-load snapshot
before acquisition or callbacks, as it already does. The normalized request and
acquisition policy then borrow that load-owned read-only value.
Declaration resolution reads the owned defaults and retains its existing copies
of selected pattern/exclusion slices and destinations in `resolvedImportPolicy`.
This separates presence-aware load input from independently owned effective
declaration policy. No public copy-count or thread-safety guarantee follows.
For placement, expose the engine's existing enum as `PlacementMode uint8` with
`PlacementModeMapping` and `PlacementModeSequenceItem`, and expose placement
fields on engine input/options records for direct construction. These remain
internal-package implementation surfaces. Root types stay root-owned; direct
casts preserve unknown values for existing preflight validation.
### Validation Conventions
Run from the repository root with an installed compatible Go toolchain and
dependencies already available. Use `GOTOOLCHAIN=local`, `GOPROXY=off`, and
`GOTELEMETRY=off`. Keep disposable artifacts outside the repository and do not
change dependencies for tooling. Focused expressions identify behavior owners;
include a newly named regression explicitly if needed. Reuse unchanged successful
checks. No timing, allocation, exact syscall, or helper-shape assertions are
acceptance criteria.
## Stage 1 — Extract Patterned Glob Entry Preparation
Finding: **C03**. Owners: `internal/engine/glob.go`, existing glob selection,
link, and directory failure tests. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'` and `git diff --check`.
1. Add `prepareGlobEntry` beside the selector. Take the filesystem access,
base path, parent `selectedImportCandidate`, `fs.DirEntry`, pattern segment,
`last` flag, and `resolvedImportPolicy`. Return a candidate, a boolean meaning
selected versus ordinary skip, and a nullable `*Diagnostic`. Use the parent's
existing `site`, which is propagated from the selector's root candidate.
No candidate-set, accounting, or traversal-state mutation belongs here.
2. Move only the patterned branch's per-entry body into this helper. Preserve
its order: `path.Match`; match/hidden filtering; lexical/access path creation
and containment; `importEntryTarget` and existing diagnostic translation;
follow filtering; intermediate-directory eligibility; host/native physical
path selection and canonicalization fallback; candidate label/site creation.
Return existing safe codes, paths, and sites. Do not turn inspection errors
into skips or skip validation merely because exclusion will later match.
3. Replace that inline body with the helper call and candidate insertion.
Leave pattern validation, `explicitGlobCandidate`, directory enumeration,
per-segment union/sorting, final-file validation, exclusions, requiredness,
and source-admission timing in the selector.
4. Keep directory entry preparation separate. Reuse existing filesystem/link
primitives; do not introduce a generic entry strategy or unify differing
missing-target, literal-link, filtering, or recursion rules. Remove imports
only when genuinely unused after extraction.
Acceptance: the selector now expresses traversal without embedding preparation
details. Reuse `glob_selection_test.go`, `glob_test.go`, filesystem-link tests,
and the preceding sprint's directory-phase regressions. They must still cover
literal hidden/link components versus wildcard filtering, aliases and native
spelling, escaping/cyclic links, matched-directory errors before exclusion,
selection order, and bounded accounting. Add a behavioral case only for a gap
identified during extraction, not a unit test asserting the new helper exists.
Focused check:
`go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'`.
## Stage 2 — Consolidate The Engine Defaults Representation
Finding: **F07**, engine representation. Owners: `internal/engine/import_spec.go`,
`load.go`, `layered.go`, `load_request.go`, `acquisition.go`, `options.go`, and
their callers/fixtures. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and `git diff --check`. Root facade callers remain pending Stage 3.
1. Retain engine `ImportDefaults` with its existing five exported fields.
Remove private `importDefaults`, without a compatibility alias. Update
engine option/request/acquisition fields, helper signatures, readers, and
fixture literals to use the canonical type and exported default-field names.
Private containing fields such as `loadRequest.importDefaults` can keep their
names. Do not rename the distinct fields of `importDeclarationSpec` or
`resolvedImportPolicy` just because they have similar meanings.
2. Expose `ImportDefaults ImportDefaults` on engine `Options[T]` and
`LayeredOptions[T]`. Remove `OptionsWithImportDefaults`,
`LayeredOptionsWithImportDefaults`, and `privateImportDefaults`. Stage 3 changes
root callers; a temporary compile failure is preferable to an interim bridge.
3. Adapt `snapshotImportDefaults` to the canonical type, retaining nil boolean
pointers and nil versus non-nil empty pattern. Keep `cloneBool` and
`cloneStrings` where they still have real consumers. Keep current snapshot
call sites until Stage 3 establishes their final ownership placement.
4. Keep effective declaration policy resolution and validation semantics intact:
explicit false beats defaults, each control resolves independently, and
selector-specific applicability and depth checks stay unchanged. Do not
combine defaults with resolved policy or registration compilation.
Acceptance: one engine input-defaults type reaches every production path and
fixture; no equivalent private record or alias remains. Inspect ordinary,
layered, transitive-import, directory/glob, and test-helper call sites. The root
cutover and removal of redundant snapshots are explicitly pending until Stage 3.
Run the import/default focused tests after Stage 3 if the root cutover prevents
building the package here; do not report that deferred check as passed.
## Stage 3 — Simplify Facade Defaults Construction And Snapshot Ownership
Finding: **F07**, defaults handoff. Owners: root `conversion.go`, `lifecycle.go`,
`layered.go`; engine defaults/preflight/coordinator owners; `facade_import_defaults_test.go`
and `internal/engine/import_spec_test.go`. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and `git diff --check`.
1. Set engine `ImportDefaults` fields directly in root `toEngineOptions` and
`toEngineLayeredOptions`. Retain `toEngineImportDefaults` as the explicit
root-to-engine type mapping, but map its booleans/pattern without copies.
Remove root `cloneImportDefaultBool` and `cloneImportDefaultStrings` after
confirming no other caller. Keep public root types and declaration contracts.
2. Keep `snapshotImportDefaults` in `loadSettings` as the load-entry ownership
operation for both APIs. Remove repeated snapshots in engine `LoadLayered`
request construction, `executeLoadRequest` acquisition-policy construction,
and `resolveImportPolicy`. Carry the owned defaults read-only thereafter.
Review all callers of those helpers, including test setup; direct declaration
resolution can read its input because it returns its own concrete policy.
3. Keep per-policy copies of the selected pattern, exclusions, and destination.
Do not borrow caller-owned storage beyond preflight, mutate the shared owned
defaults, or remove copying from unrelated option/record paths. Each new load
snapshots its own current inputs; add no reuse cache or ownership flag.
4. Replace the private conversion-snapshot fragment in
`TestImportDefaultsPreserveEmptyPatternAndRequiredness`, which converts options,
mutates inputs, and calls engine `Load`. It should no longer demand a snapshot
from a private conversion helper. Keep that test's public nil/empty,
requiredness, and inspection-evidence checks.
5. Protect the public snapshot contract with a compact ordinary/layered table:
use a main document with a version and imports, enable
`SourceVersionModeIfPresent`, and use a configuration type with version and
imported-value fields. In the main version-check callback, mutate the caller's
recursive default from true to false and its existing pattern element from
`*.cfg` to `*.skip`. This callback runs after preflight but before imported
discovery. Verify the original recursive/pattern selection still supplies a
value from a nested imported file. Use existing fixture helpers and no
concurrent mutation. A subsequent load with those changed defaults must omit
that imported contribution rather than reuse the prior snapshot. Keep fixtures
reusable or create fresh readers for each load.
6. Reuse or extend the small snapshot/policy tests for all four optional booleans,
explicit false, pattern mutation, nil, and non-nil empty pattern. Preserve
existing invalid-pattern-before-read/hook tests and declaration override,
transitive, underlay, and selected-overlay coverage. Do not assert exact copy
counts, pointer identities, or the private conversion boundary as public policy.
Acceptance: the engine/facade cutover builds; public defaults remain detached
before acquisition/callbacks, reused loads remain independent, and only genuine
ownership copies remain. Confirm the absence of full defaults snapshots at the
three removed downstream sites by source review, not production instrumentation.
Focused checks:
`go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and
`go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`.
## Stage 4 — Construct Placement Directly Across The Facade
Finding: **F07**, placement handoff. Owners: engine `destination.go`, `load.go`,
`layered.go`, normalized input and placement consumers; root `lifecycle.go`,
`layered.go`, and placement fixtures. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, and `git diff --check`.
1. Rename the existing engine enum and its constants to `PlacementMode`,
`PlacementModeMapping`, and `PlacementModeSequenceItem`, preserving underlying
`uint8` values and zero semantics. Use the same type throughout engine
placement parsing, normalization, acquisition, source evidence, and version
projection. Do not add a second raw-mode record or type alias.
2. Expose engine `Options[T].Destination` and `.PlacementMode`, and
`Input.PlacementMode`. Normalized `loadInput` and other private owner fields
can retain their names. Populate the normalized input from those fields.
Remove `OptionsWithPlacement` and `InputWithPlacement` and migrate all callers,
including the direct engine construction in root `records_test.go`.
3. In root `toEngineOptions` and `toEngineInput`, set destination using the existing
`toEnginePath` conversion and placement using
`engine.PlacementMode(options.PlacementMode)` or its input equivalent.
Preserve invalid enum values for `normalizeInput` to diagnose before reads.
Do not add validation/defaulting to the facade or a new setter/cast function.
4. Preserve normalization of literal/parsed destination keys, root-item rejection,
wrapper accounting, mounted/item source coordinates, and version projection.
Root `Path` is immutable and `toEnginePath` carries its private engine value;
keep normalization and actual mutable ownership copies. Do not redesign path
conversion or add redundant clones solely to replace the deleted decorator.
5. Leave source-result transport such as `SourceInfoPlacementModeForFacade`,
source-version conversion, sequence registration preparation, and opaque
records alone. They are not input setters targeted by this step. Adapt tests
mechanically to new engine construction without weakening public assertions.
Acceptance: both public loading APIs retain zero/mapping and sequence-item
placement, errors before input consumption, final values, source metadata, and
history. Reuse the public placement tests plus destination/accounting,
item-import, mounted-version, and invalid-option cases. Root signatures still
contain no engine types, and engine exports remain below the internal boundary.
Focused checks:
`go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'` and
`go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`.
## Stage 5 — Integrate, Document, And Verify The Result
Findings: final acceptance for **C03, F07**. Status: **complete**. Validation
passed on Linux/amd64 (Go 1.26.10):
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh`,
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and
`git diff --check`. Existing integration coverage was reviewed; no additional
case was needed.
1. Review both paths together: defaults resolving wildcard discovery in ordinary,
layered, and transitive imports; placement carrying the resulting sources
through composition and inspection; and failures stopping before later hooks.
Existing integration tests own these interactions. Add only a concrete missing
case rather than a second copy of the complete matrix or a new fixture framework.
2. Inspect the resulting handoffs. Confirm the glob selector no longer embeds
per-entry preparation, the engine has one defaults input type, direct fields
replaced the four input decorators, and the load snapshot is not repeated at
each private representation. Keep domain-specific policy/registration and
root conversion boundaries, necessary ownership copies, and existing budgets.
3. Update `docs/internal/imports.md` for the selector/preparation split and
`docs/internal/overview.md` for defaults conversion, preflight snapshot ownership,
and direct placement construction. Keep algorithms private and avoid public
performance or copy-count guarantees. No ADR/policy change is needed.
4. Replace C03/F07 detail in the first two `docs/roadmap/future.md` entries with
short pointers to `internal-boundaries.md`. Retain their heading anchors for
existing audit links; leave other future candidates unchanged. Correct old
roadmap references that would otherwise mistake this replacement plan for
the previous sprint's completed record: use
`bb5540b:docs/roadmap/implementation.md` for that historical plan. Do not reopen
the prior audit or retire unrelated planning records in this sprint.
5. Format changed Go files, finish deferred checks, run the final checks below,
and inspect the complete diff for API, dependency, semantics, or scope changes.
Record actual results and platform limitations here. Commit focused nonempty
units and hand off a clean workspace; do not create empty validation commits.
Final validation, following the existing development guide:
```sh
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...
git diff --check
```
The everyday check includes consumers, documentation links, and public/dependency
boundary tests. Confirm those existing tests ran rather than adding redundant
final invocations. Report filesystem/platform skips accurately; express macOS
validation, CI completion, release publication, extended fuzz campaigns, and
benchmarks are not additional acceptance requirements.
No product or architectural question remains open. The concrete choices above
complete the plan without changing the roadmap's public contracts; private
formatting and file organization can follow local idiom.

View File

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

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

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

View File

@@ -19,6 +19,28 @@ func ExampleLoad() {
// Output: {true 3} <nil>
}
func ExampleLoad_sequenceDeclaration() {
type Item struct {
ID string `yaml:"id"`
}
type Config struct {
Items []Item `yaml:"items"`
}
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
sequences:
- destination: [items]
key: id
items:
- id: prompt
`)))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result.Config.Items[0].ID)
// Output: prompt
}
func ExampleNoSource() {
type Config struct {
Workers int `yaml:"workers"`
@@ -154,7 +176,14 @@ func ExampleOverride() {
})
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
Definition: configkit.Definition[Config]{
Defaults: func() Config { return Config{Workers: 4} },
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("workers"), &configuration.Workers, 100,
"preserve an explicitly supplied worker count")
return nil
},
},
Overrides: overrides,
})
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)

View File

@@ -0,0 +1,154 @@
package consumers
import (
"errors"
"fmt"
"io/fs"
"path"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// CollectionBundleConfig is a representative application-owned collection
// shape. Collection and prompt version labels remain ordinary domain fields.
type CollectionBundleConfig struct {
Version string `yaml:"version"`
Collections map[string]CollectionDefinition `yaml:"collections"`
}
type CollectionDefinition struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Prompts map[string]CollectionPrompt `yaml:"prompts"`
Profiles map[string]CollectionProfile `yaml:"profiles"`
}
type CollectionPrompt struct {
Version string `yaml:"version"`
DefaultProfile string `yaml:"default_profile"`
ContentFile string `yaml:"content_file"`
ResourceText string `yaml:"resource_text"`
Messages []CollectionMessage `yaml:"messages"`
}
type CollectionMessage struct {
Role string `yaml:"role"`
Content string `yaml:"content"`
}
type CollectionProfile struct {
Format string `yaml:"format"`
Temperature float64 `yaml:"temperature"`
}
// LoadCollectionBundle demonstrates one layered collection load. Its main
// document owns the application schema version; the mounted entry declares
// relative destinations for collection metadata, prompt/profile peers, and
// their nested imports. The operator version override remains a separate tier.
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
if err != nil {
return nil, err
}
bundle := configkit.NewFilesystem("collection bundle", files)
collectionDestination := configkit.Keys("collections", "weatherreporter")
promptDestination := collectionDestination.Key("prompts")
return configkit.LoadLayered(
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
configkit.LayeredOptions[CollectionBundleConfig]{
Definition: configkit.Definition[CollectionBundleConfig]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, _ *configkit.Validation) error {
if !version.Present || version.Text != "1" {
return errors.New("unsupported application schema version")
}
return nil
}},
Resolve: resolveCollectionResources,
Validate: validateCollectionBundle,
},
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{
Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination,
}}},
{Inputs: []configkit.Input{{
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
}}},
},
},
)
}
func resolveCollectionResources(configuration *CollectionBundleConfig, resolution *configkit.Resolution) error {
for collectionID, collection := range configuration.Collections {
for promptID, prompt := range collection.Prompts {
if prompt.ContentFile == "" {
continue
}
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
info, found := resolution.InputInfo(contentPath)
if !found || len(info.Origin.Locations) != 1 {
return fmt.Errorf("prompt resource has no single declaring source")
}
resourceSource, found := resolution.Source(info.Origin.Locations[0].SourceID)
if !found {
return fmt.Errorf("prompt resource source is unavailable")
}
context, found := resolution.SourceContext(resourceSource.ID)
if !found {
return fmt.Errorf("prompt resource context is unavailable")
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
if err != nil {
return fmt.Errorf("read prompt resource: %w", err)
}
prompt.ResourceText = string(data)
collection.Prompts[promptID] = prompt
resolution.Record(
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
)
}
configuration.Collections[collectionID] = collection
}
return nil
}
func validateCollectionBundle(configuration *CollectionBundleConfig, validation *configkit.Validation) {
for collectionID, collection := range configuration.Collections {
for promptID, prompt := range collection.Prompts {
if prompt.DefaultProfile == "" {
continue
}
if _, found := collection.Profiles[prompt.DefaultProfile]; !found {
validation.Error(configkit.Keys("collections", collectionID, "prompts", promptID, "default_profile"), "must name a profile in this collection")
}
}
}
}
// ReadCollectionPromptContent follows the effective content field's physical
// source, including when it was inherited from a peer prompt.
func ReadCollectionPromptContent(result *configkit.Result[CollectionBundleConfig], collectionID, promptID string) (string, error) {
if result == nil {
return "", errors.New("collection result is nil")
}
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
info, found := result.Metadata().Lookup(contentPath)
if !found || len(info.Origin.Locations) != 1 {
return "", errors.New("prompt resource has no single declaring source")
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found {
return "", errors.New("prompt resource context is unavailable")
}
prompt, found := result.Config.Collections[collectionID].Prompts[promptID]
if !found {
return "", errors.New("prompt is unavailable")
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
if err != nil {
return "", err
}
return string(data), nil
}

View File

@@ -0,0 +1,137 @@
package consumers_test
import (
"errors"
"fmt"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_inputDestination() {
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.Mapping("weather prompt", map[string]any{
"summarize.alerts": map[string]any{"version": "1"},
}),
Destination: configkit.Keys("collections", "weather.reporter", "prompts"),
})
if err != nil {
fmt.Println(err)
return
}
root, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.Bytes("standalone", []byte("name: standalone\n")),
})
if err != nil {
fmt.Println(err)
return
}
_, found := mounted.Config["collections"]
fmt.Println(found, root.Config["name"])
// Output: true standalone
}
func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
result, err := consumers.LoadCollectionBundle()
if err != nil {
t.Fatalf("LoadCollectionBundle: %v", err)
}
collection := result.Config.Collections["weatherreporter"]
if result.Config.Version != "1" || collection.Name != "Weather Reporter" || collection.Version != "2.4.0" {
t.Fatalf("main and collection versions = %q, %#v", result.Config.Version, collection)
}
brief := collection.Prompts["brief"]
custom := collection.Prompts["custom"]
if brief.Version != "2" || brief.DefaultProfile != "compact" || brief.ContentFile != "welcome.txt" || len(brief.Messages) != 1 {
t.Fatalf("version-only override replaced lower prompt fields: %#v", brief)
}
if custom.Version != "2" || custom.DefaultProfile != "weather" || custom.ContentFile != brief.ContentFile ||
len(custom.Messages) != 1 || custom.ResourceText != "Welcome to the weather collection.\n" {
t.Fatalf("inherited prompt = %#v", custom)
}
weatherProfile := collection.Profiles["weather"]
if weatherProfile.Format != "text" || weatherProfile.Temperature != 0.4 {
t.Fatalf("inherited profile = %#v", weatherProfile)
}
resource, err := consumers.ReadCollectionPromptContent(result, "weatherreporter", "custom")
if err != nil || resource != custom.ResourceText {
t.Fatalf("inherited prompt resource = %q, %v", resource, err)
}
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
}
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
collectionVersion, ok := result.Metadata().Lookup(collectionVersionPath)
if !ok || !collectionVersion.Explicit || collectionVersion.Origin.Layer != configkit.LayerUnderlay || len(collectionVersion.Origin.Locations) != 1 {
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
}
var metadataSource configkit.SourceInfo
var collectionEntrySource configkit.SourceInfo
var versionSource configkit.SourceInfo
var parentPromptSource configkit.SourceInfo
var promptIndexSource configkit.SourceInfo
var profileIndexSource configkit.SourceInfo
var promptVersionSource configkit.SourceInfo
for _, source := range result.Sources() {
switch source.Path {
case "collections/weather/collection.yml":
collectionEntrySource = source
case "collections/weather/metadata.yml":
metadataSource = source
case "collections/weather/version.yml":
versionSource = source
case "collections/weather/prompts/index.yml":
promptIndexSource = source
case "collections/weather/prompts/brief.yml":
parentPromptSource = source
case "collections/weather/profiles/index.yml":
profileIndexSource = source
case "operators/weather-prompt-version.yml":
promptVersionSource = source
}
}
collectionVersionLocation := collectionVersion.Origin.Locations[0]
if collectionEntrySource.ID != "source-2" || collectionEntrySource.Destination.String() != "/collections/weatherreporter" ||
metadataSource.ID == "" || metadataSource.Destination.String() != "/collections/weatherreporter" ||
metadataSource.ImportedFrom == nil || metadataSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
versionSource.ID == "" || collectionVersionLocation.SourceID != versionSource.ID || versionSource.Destination.String() != "/collections/weatherreporter" ||
versionSource.ImportedFrom == nil || versionSource.ImportedFrom.SourceID != metadataSource.ID || collectionVersionLocation.Line != 1 {
t.Fatalf("nested collection version provenance = entry %#v, metadata %#v, version %#v, location %#v", collectionEntrySource, metadataSource, versionSource, collectionVersionLocation)
}
if promptIndexSource.ID == "" || promptIndexSource.Destination.String() != "/collections/weatherreporter/prompts" ||
promptIndexSource.ImportedFrom == nil || promptIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
profileIndexSource.ID == "" || profileIndexSource.Destination.String() != "/collections/weatherreporter/profiles" ||
profileIndexSource.ImportedFrom == nil || profileIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID {
t.Fatalf("nested prompt/profile placement = prompt %#v profile %#v", promptIndexSource, profileIndexSource)
}
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
t.Fatalf("prompt version source = %#v", promptVersionSource)
}
contentPath := configkit.Keys("collections", "weatherreporter", "prompts", "custom", "content_file")
contentInfo, ok := result.Metadata().Lookup(contentPath)
if !ok || !contentInfo.Explicit || len(contentInfo.Origin.Inheritance) != 1 || len(contentInfo.Origin.Locations) != 1 {
t.Fatalf("inherited content field metadata = %#v, %v", contentInfo, ok)
}
contentLocation := contentInfo.Origin.Locations[0]
if parentPromptSource.ID == "" || contentLocation.SourceID != parentPromptSource.ID || contentLocation.Line != 4 ||
parentPromptSource.Destination.String() != "/collections/weatherreporter/prompts" {
t.Fatalf("inherited resource source coordinates = %#v, source %#v", contentLocation, parentPromptSource)
}
}
func TestCollectionPeerPromptConflictsRemainStrict(t *testing.T) {
destination := configkit.Keys("collections", "weatherreporter", "prompts")
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: configkit.Bytes("peer-a", []byte("brief: {version: '1'}\n")), Destination: destination},
{Source: configkit.Bytes("peer-b", []byte("brief: {version: '1'}\n")), Destination: destination},
}}},
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
t.Fatalf("duplicate peer prompt error = %v", err)
}
}

View File

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

View File

@@ -9,7 +9,7 @@ import (
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
//go:embed testdata/native-source testdata/layered-bundle
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
var bundledFiles embed.FS
// BundledConfig is a small configuration loaded directly from an embedded file.
@@ -18,6 +18,30 @@ type BundledConfig struct {
Enabled bool `yaml:"enabled"`
}
type PromptBundleConfig struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Prompts map[string]BundlePrompt `yaml:"prompts"`
}
type BundlePrompt struct {
Summary string `yaml:"summary"`
}
// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose
// destinations are declared relative to the entry document.
func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports")
if err != nil {
return nil, err
}
return configkit.Load[PromptBundleConfig](
configkit.NewFilesystem("prompt bundle", files).File("config.yml"),
configkit.Options[PromptBundleConfig]{Imports: true},
)
}
// LoadBundled demonstrates a native filesystem source without layered options.
func LoadBundled() (*configkit.Result[BundledConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/native-source")

View File

@@ -20,6 +20,34 @@ func Example_nativeFilesystem() {
// Output: embedded false
}
func Example_rootRelativeImports() {
result, err := consumers.LoadPromptBundle()
if err != nil {
fmt.Println(err)
return
}
source := result.Sources()[2]
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>
}
func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) {
result, err := consumers.LoadPromptBundle()
if err != nil {
t.Fatal(err)
}
if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." {
t.Fatalf("root import result = %#v", result.Config)
}
sources := result.Sources()
if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() ||
sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() ||
sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" ||
sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID {
t.Fatalf("root import sources = %#v", sources)
}
}
func Example_layeredCatalog() {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil {
@@ -32,6 +60,58 @@ func Example_layeredCatalog() {
// Output: production false 0 bundle example Welcome from the bundle. <nil>
}
func Example_sourceVersionAdoption() {
result, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent)
if err != nil {
fmt.Println(err)
return
}
checks, redacted := 0, true
for _, source := range result.Sources() {
if check := source.VersionCheck; check != nil {
checks++
redacted = redacted && check.Redacted && check.Version.Text == ""
}
}
required, requiredErr := consumers.LoadVersionedService(configkit.SourceVersionModeRequired)
fmt.Println(result.Config.Service.Version, result.Config.Service.Metadata.Release,
checks, redacted, required != nil && requiredErr == nil)
// Output: service-v2 application-2026.10 2 true true
}
func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
for _, mode := range []configkit.SourceVersionMode{
configkit.SourceVersionModeIfPresent,
configkit.SourceVersionModeRequired,
} {
result, err := consumers.LoadVersionedService(mode)
if err != nil || result == nil || result.Config.Service.Version != "service-v2" ||
result.Config.Service.Name != "example service" || result.Config.Service.Endpoint != "https://service.example.invalid" ||
result.Config.Service.Metadata.Release != "application-2026.10" {
t.Fatalf("versioned service mode %d = %#v, %v", mode, result, err)
}
sources := result.Sources()
if len(sources) != 2 {
t.Fatalf("versioned service sources = %#v", sources)
}
for _, source := range sources {
check := source.VersionCheck
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
!check.Redacted || check.Version.Text != "" {
t.Fatalf("safe source version evidence = %#v", sources)
}
}
}
_, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent,
configkit.Override{Path: configkit.Keys("service", "version"), Value: "cli-v3", Flag: "--service-version"})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
loadError.Diagnostics()[0].Code != configkit.CodeOverrideVersion ||
loadError.Diagnostics()[0].Path.String() != "/service/version" {
t.Fatalf("mounted main version override error = %v", err)
}
}
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil {

View File

@@ -0,0 +1,67 @@
package consumers
import (
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// PromptCatalogConfig is an application-owned prompt collection shape.
type PromptCatalogConfig struct {
Prompts []PromptRecord `yaml:"prompts"`
}
// PromptRecord keeps its identity and message meaning in the consumer.
type PromptRecord struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Messages []string `yaml:"messages"`
Resource string `yaml:"resource"`
}
// LoadPromptCatalog discovers explicitly registered prompt records in the main
// tier and replaces the lower-priority default collection as a whole.
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
return configkit.LoadLayered(configkit.Input{
Source: configkit.File(mainPath),
Imports: true,
}, configkit.LayeredOptions[PromptCatalogConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
}}}},
ImportDefaults: configkit.ImportDefaults{
Pattern: []string{"curated*.yml", "generated-*.yml"},
},
Definition: promptCatalogDefinition(),
})
}
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
Imports: true, Definition: promptCatalogDefinition(),
})
}
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
return configkit.Definition[PromptCatalogConfig]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
},
Validate: validatePromptCatalog,
}
}
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
for index, prompt := range configuration.Prompts {
if strings.TrimSpace(prompt.Name) == "" {
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
}
if len(prompt.Messages) == 0 {
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
}
}
if len(configuration.Prompts) == 0 {
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
}
}

View File

@@ -0,0 +1,135 @@
package consumers_test
import (
"errors"
"fmt"
"io/fs"
pathpkg "path"
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_identifiedPromptCatalog() {
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
if err != nil {
fmt.Println(err)
return
}
ids := make([]string, 0, len(result.Config.Prompts))
for _, prompt := range result.Config.Prompts {
ids = append(ids, prompt.ID)
}
emptySearches := 0
for _, source := range result.Sources() {
for _, selection := range source.ImportSelections {
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
emptySearches++
}
}
}
fmt.Println(ids, emptySearches, err)
// Output: [weather curated welcome main] 1 <nil>
}
func Example_requiredPromptCatalog() {
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result.Config.Prompts[0].ID, err)
// Output: required <nil>
}
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("load prompt catalog: %v (%#v)", err, loadError.Diagnostics())
}
t.Fatal(err)
}
if len(result.Config.Prompts) != 4 {
t.Fatalf("prompt records = %#v", result.Config.Prompts)
}
ids := []string{"weather", "curated", "welcome", "main"}
for index, id := range ids {
if result.Config.Prompts[index].ID != id {
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
}
}
for _, prompt := range result.Config.Prompts {
if prompt.ID == "default" {
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
}
if prompt.ID == "ignored" {
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
}
}
var itemImports, helperImports int
for _, source := range result.Sources() {
switch filepath.Base(source.Path) {
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
t.Fatalf("item source placement = %#v", source)
}
itemImports++
case "curated-id.yml":
if source.PlacementMode != configkit.PlacementModeMapping || source.ImportedFrom == nil {
t.Fatalf("helper source = %#v", source)
}
helperImports++
}
}
if itemImports != 3 || helperImports != 1 {
t.Fatalf("item imports = %d, helper imports = %d, sources = %#v", itemImports, helperImports, result.Sources())
}
if result.Config.Prompts[1].ID != "curated" {
t.Fatalf("helper did not provide prompt ID: %#v", result.Config.Prompts[1])
}
}
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
if err != nil {
t.Fatal(err)
}
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
}
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
info, ok := result.Metadata().Lookup(resourcePath)
if !ok || len(info.Origin.Locations) != 1 {
t.Fatalf("resource origin = %#v, %v", info, ok)
}
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
t.Fatalf("resource source context = %#v, %v", context, ok)
}
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
if err != nil || string(contents) != "Welcome resource text.\n" {
t.Fatalf("mounted resource = %q, %v", contents, err)
}
}
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
t.Fatalf("simple required prompt = %#v, %v", result, err)
}
}
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
loadError.Diagnostics()[0].Path.String() != "/prompts/0/messages" {
t.Fatalf("consumer domain validation error = %v", err)
}
}

View File

@@ -0,0 +1,145 @@
package consumers
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// SequenceCatalogConfig is a source-backed prompt catalog with an application
// supplied run label and a value derived from that label during resolution.
type SequenceCatalogConfig struct {
RunLabel string `yaml:"run_label"`
ResolvedLabel string `yaml:"resolved_label"`
Prompts []SequenceCatalogEntry `yaml:"prompts"`
}
// SequenceCatalogEntry keeps prompt meaning, resources, and credentials in the
// consuming application while ConfigKit handles collection assembly.
type SequenceCatalogEntry struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Messages []string `yaml:"messages"`
Resource string `yaml:"resource"`
Credential configkit.Secret `yaml:"credential"`
}
// LoadSequenceCatalog searches its caller-selected main path before a custom
// fallback directory, assembles imported fragments over a file-backed underlay,
// applies one selected collection overlay, then resolves and validates once.
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
absoluteMain, err := filepath.Abs(mainPath)
if err != nil {
return nil, err
}
root := filepath.Dir(absoluteMain)
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
mainSearch := configkit.SearchConfig("", configkit.Searches(
configkit.Paths(absoluteMain),
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
}),
), configkit.SearchOptions{Required: true})
definition := configkit.Definition[SequenceCatalogConfig]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
},
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
configuration.ResolvedLabel = "for:" + configuration.RunLabel
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization,
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
return nil
},
Validate: validateSequenceCatalog,
}
return configkit.LoadLayered(configkit.Input{
Source: mainSearch,
Imports: true,
Filesystem: &configkit.FilesystemContext{Root: root},
}, configkit.LayeredOptions[SequenceCatalogConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: filesystem.File("underlay.yml"),
}}}},
Overlay: overlayName,
OverlayFilesystem: filesystemContext,
Overrides: []configkit.Override{{
Path: configkit.Keys("run_label"), Value: runLabel, Flag: "--run-label",
}},
Definition: definition,
})
}
// ReadSequenceCatalogResource follows the final prompt field's source context,
// including when record inheritance retained a resource from an underlay.
func ReadSequenceCatalogResource(result *configkit.Result[SequenceCatalogConfig], promptID string) (string, error) {
if result == nil {
return "", errors.New("sequence catalog result is nil")
}
for index, prompt := range result.Config.Prompts {
if prompt.ID != promptID {
continue
}
resourcePath := configkit.Keys("prompts").Index(index).Key("resource")
info, found := result.Metadata().Lookup(resourcePath)
if !found || len(info.Origin.Locations) != 1 {
return "", errors.New("prompt resource has no single declaring source")
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found {
return "", errors.New("prompt resource source context is unavailable")
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.Resource))
if err != nil {
return "", fmt.Errorf("read prompt resource: %w", err)
}
return string(data), nil
}
return "", fmt.Errorf("prompt %q is unavailable", promptID)
}
func validateSequenceCatalog(configuration *SequenceCatalogConfig, validation *configkit.Validation) {
for index, prompt := range configuration.Prompts {
if strings.TrimSpace(prompt.Name) == "" {
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
}
if len(prompt.Messages) == 0 {
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
}
}
if len(configuration.Prompts) == 0 {
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
}
}
// NamespacedMapCatalogConfig demonstrates typed application maps whose
// independent prompt sequences use root and dynamic namespace registrations.
type NamespacedMapCatalogConfig struct {
Prompts []map[string]any `yaml:"prompts"`
Collections map[string]map[string]any `yaml:"collections"`
}
// LoadNamespacedMapCatalog applies the same scalar identity constraint to one
// root collection and each namespace's independently scoped collection.
func LoadNamespacedMapCatalog(source configkit.Source) (*configkit.Result[NamespacedMapCatalogConfig], error) {
selector := configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}
return configkit.Load(source, configkit.Options[NamespacedMapCatalogConfig]{
Definition: configkit.Definition[NamespacedMapCatalogConfig]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
configkit.SequenceIdentityMatching(selector, "id"),
},
},
})
}

View File

@@ -0,0 +1,239 @@
package consumers_test
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_sequenceCatalog() {
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
restoreOld, restoreNew := preserveEnvironment(oldSecret), preserveEnvironment(newSecret)
defer restoreOld()
defer restoreNew()
_ = os.Setenv(oldSecret, "disposable-old-example-credential")
_ = os.Setenv(newSecret, "disposable-new-example-credential")
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "preview")
if err != nil {
fmt.Println(err)
return
}
ids := make([]string, 0, len(result.Config.Prompts))
for _, prompt := range result.Config.Prompts {
ids = append(ids, prompt.ID)
}
shared := configkit.Keys("prompts").Index(1).Key("name")
info, _ := result.Metadata().Lookup(shared)
resource, resourceErr := consumers.ReadSequenceCatalogResource(result, "concise")
fmt.Println(ids, result.Config.ResolvedLabel, info.Explicit, len(result.Metadata().History(shared)), resource, resourceErr == nil, err)
// Output: [foundation shared welcome concise extra] for:preview true 2 Foundation resource text.
// true <nil>
}
func TestSequenceCatalogSelectedOverlayOperations(t *testing.T) {
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
t.Setenv(oldSecret, "sequence-catalog-old-canary")
t.Setenv(newSecret, "sequence-catalog-new-canary")
tests := []struct {
name string
wantIDs []string
shared *consumers.SequenceCatalogEntry
}{
{name: "append", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}},
{name: "upsert", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}, shared: &consumers.SequenceCatalogEntry{ID: "shared", Name: "Shared replacement", Messages: []string{"Replacement message"}, Credential: configkit.SecretEnv(newSecret)}},
{name: "replace", wantIDs: []string{"replacement", "last"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), test.name, "test")
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("load selected overlay: %v (%#v)", err, loadError.Diagnostics())
}
t.Fatal(err)
}
gotIDs := make([]string, 0, len(result.Config.Prompts))
for _, prompt := range result.Config.Prompts {
gotIDs = append(gotIDs, prompt.ID)
}
if strings.Join(gotIDs, ",") != strings.Join(test.wantIDs, ",") {
t.Fatalf("prompt order = %v, want %v", gotIDs, test.wantIDs)
}
if test.shared == nil {
return
}
shared := result.Config.Prompts[1]
if shared.ID != test.shared.ID || shared.Name != test.shared.Name || len(shared.Messages) != 1 ||
shared.Messages[0] != test.shared.Messages[0] || shared.Description != "" || shared.Resource != "" || shared.Credential.EnvName() != newSecret {
t.Fatalf("upsert did not replace the complete matched record: %#v", shared)
}
})
}
}
func TestSequenceCatalogInspectionTracksResourcesAndSecretHistory(t *testing.T) {
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
const oldCanary = "sequence-catalog-old-canary"
const newCanary = "sequence-catalog-new-canary"
t.Setenv(oldSecret, oldCanary)
t.Setenv(newSecret, newCanary)
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "history")
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("load sequence catalog: %v (%#v)", err, loadError.Diagnostics())
}
t.Fatal(err)
}
resourcePath := configkit.Keys("prompts").Index(3).Key("resource")
resourceInfo, found := result.Metadata().Lookup(resourcePath)
if !found || len(resourceInfo.Origin.Locations) != 1 || len(resourceInfo.Origin.Inheritance) != 1 {
t.Fatalf("inherited resource inspection = %#v, %v", resourceInfo, found)
}
resource, err := consumers.ReadSequenceCatalogResource(result, "concise")
if err != nil || resource != "Foundation resource text.\n" {
t.Fatalf("resource from inherited source context = %q, %v", resource, err)
}
if len(result.Metadata().History(configkit.Keys("resolved_label"))) == 0 {
t.Fatal("recorded CLI-driven resolution was absent from final inspection")
}
credentialPath := configkit.Keys("prompts").Index(1).Key("credential")
credentialHistory := result.Metadata().History(credentialPath)
if len(credentialHistory) == 0 {
t.Fatal("upsert did not retain the replaced secret reference in history")
}
var explanation bytes.Buffer
if err := result.WriteExplanation(&explanation, credentialPath); err != nil {
t.Fatal(err)
}
if strings.Contains(explanation.String(), oldCanary) || strings.Contains(explanation.String(), newCanary) {
t.Fatalf("secret explanation exposed a credential value: %s", explanation.String())
}
}
func TestNamespacedTypedMapIdentityScopesAreIndependent(t *testing.T) {
source := configkit.Bytes("namespaced maps", []byte(`
prompts:
- id: shared
text: root prompt
collections:
team:
prompts:
- id: shared
text: team prompt
operations:
prompts:
- id: shared
text: operations prompt
`))
result, err := consumers.LoadNamespacedMapCatalog(source)
if err != nil {
t.Fatal(err)
}
if result.Config.Prompts[0]["text"] != "root prompt" ||
result.Config.Collections["team"]["prompts"].([]any)[0].(map[string]any)["text"] != "team prompt" ||
result.Config.Collections["operations"]["prompts"].([]any)[0].(map[string]any)["text"] != "operations prompt" {
t.Fatalf("typed map collections = %#v", result.Config)
}
}
func TestNamespacedTypedMapIdentityFindsCollectionsIntroducedByInheritance(t *testing.T) {
result, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("inherited namespace", []byte(`
collections:
source:
prompts:
- id: shared
text: inherited prompt
copied:
extends: source
`)))
if err != nil {
t.Fatal(err)
}
copied := result.Config.Collections["copied"]["prompts"].([]any)
if len(copied) != 1 || copied[0].(map[string]any)["text"] != "inherited prompt" {
t.Fatalf("inherited typed-map collection = %#v", result.Config.Collections["copied"])
}
}
func Example_yamlDeclaredIdentityOnly() {
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
configkit:
sequences:
- destination: [prompts]
key: id
prompts:
- id: initial
`)), configkit.Options[map[string]any]{
Overrides: []configkit.Override{{
Path: configkit.Keys("prompts"),
Value: []any{
map[string]any{"id": "same"},
map[string]any{"id": "same"},
},
Flag: "--prompts",
}},
})
fmt.Println(len(result.Config["prompts"].([]any)), err)
// Output: 2 <nil>
}
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
configkit:
sequences:
- destination: [prompts]
key: id
prompts:
- id: initial
`)), configkit.Options[map[string]any]{
Overrides: []configkit.Override{{
Path: configkit.Keys("prompts"),
Value: []any{
map[string]any{"id": "same"},
map[string]any{"id": "same"},
},
Flag: "--prompts",
}},
})
if err != nil {
t.Fatalf("YAML-only identity incorrectly became an application constraint: %v", err)
}
prompts := result.Config["prompts"].([]any)
if len(prompts) != 2 || prompts[0].(map[string]any)["id"] != prompts[1].(map[string]any)["id"] {
t.Fatalf("CLI result = %#v", result.Config)
}
}
func TestNamespacedTypedMapRejectsDuplicatesWithinOneCollection(t *testing.T) {
_, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("duplicate namespace", []byte(`
collections:
team:
prompts:
- id: duplicate
- id: duplicate
`)))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict ||
loadError.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
diagnostics := []configkit.Diagnostic(nil)
if loadError != nil {
diagnostics = loadError.Diagnostics()
}
t.Fatalf("duplicate namespaced identity error = %v (%#v)", err, diagnostics)
}
}

View File

@@ -0,0 +1,64 @@
package consumers
import (
"errors"
"io/fs"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// VersionedServiceConfig demonstrates a mounted main input whose source-local
// schema version is retained at the effective service path.
type VersionedServiceConfig struct {
Service struct {
Version string `yaml:"version"`
Name string `yaml:"name"`
Endpoint string `yaml:"endpoint"`
Metadata struct {
Release string `yaml:"release"`
} `yaml:"metadata"`
} `yaml:"service"`
}
// LoadVersionedService checks the main and underlay declarations with one
// application policy. The local rule path is "version" for each source; the
// main input's destination places its protected value at /service/version.
// Optional CLI overrides are passed through to ConfigKit for normal protection.
func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configkit.Override) (*configkit.Result[VersionedServiceConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/source-version")
if err != nil {
return nil, err
}
main, err := fs.ReadFile(files, "main.yml")
if err != nil {
return nil, err
}
underlay, err := fs.ReadFile(files, "underlay.yml")
if err != nil {
return nil, err
}
definition := configkit.Definition[VersionedServiceConfig]{
Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
if !version.Present || version.Null || version.Text == "" {
return errors.New("a nonempty source version is required")
}
return nil
},
},
Secret: func(path configkit.Path) bool {
return path.String() == "/service/version"
},
}
return configkit.LoadLayered(configkit.Input{
Source: configkit.Bytes("service main", main), Destination: configkit.Keys("service"),
}, configkit.LayeredOptions[VersionedServiceConfig]{
Definition: definition,
SourceVersionMode: mode,
Overrides: overrides,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("service defaults", underlay), Destination: configkit.Keys("service"),
}}}},
})
}

View File

@@ -0,0 +1,9 @@
configkit:
imports:
- path: metadata.yml
destination: []
- path: prompts/index.yml
destination: [prompts]
- path: profiles/index.yml
destination: [profiles]
name: Weather Reporter

View File

@@ -0,0 +1,4 @@
configkit:
imports:
- path: version.yml
description: Imported collection metadata.

View File

@@ -0,0 +1,3 @@
compact:
format: text
temperature: 0.2

View File

@@ -0,0 +1,4 @@
configkit:
imports:
- path: compact.yml
- path: weather.yml

View File

@@ -0,0 +1,3 @@
weather:
extends: compact
temperature: 0.4

View File

@@ -0,0 +1,7 @@
brief:
version: "1"
default_profile: compact
content_file: welcome.txt
messages:
- role: user
content: Write a concise weather summary.

View File

@@ -0,0 +1,3 @@
custom:
extends: brief
default_profile: weather

View File

@@ -0,0 +1,4 @@
configkit:
imports:
- path: brief.yml
- path: custom.yml

View File

@@ -0,0 +1 @@
Welcome to the weather collection.

View File

@@ -0,0 +1 @@
version: "2.4.0"

View File

@@ -0,0 +1,2 @@
brief:
version: "2"

View File

@@ -0,0 +1,6 @@
configkit:
imports:
- path: metadata.yml
- path: prompts/brief.yml
destination: [prompts]
name: Root import example

View File

@@ -0,0 +1,2 @@
version: "1.0"
description: Metadata imported into the root document.

View File

@@ -0,0 +1,2 @@
brief:
summary: A root-loaded prompt.

View File

@@ -0,0 +1,19 @@
configkit:
sequences:
- destination: [prompts]
key: id
mode: replace
imports:
- dir: prompts
exclude: [generated-ignored.yml]
destination: [prompts]
placement: sequence-item
- dir: featured
pattern: [weather-*.yml]
destination: [prompts]
placement: sequence-item
- dir: optional
prompts:
- id: main
name: Main authored prompt
messages: [Use the main prompt.]

View File

@@ -0,0 +1,4 @@
id: weather
name: Weather forecast
messages:
- Describe today's forecast.

View File

@@ -0,0 +1,9 @@
configkit:
sequences:
- destination: [prompts]
key: id
mode: replace
prompts:
- id: invalid
name: Invalid prompt
messages: []

View File

@@ -0,0 +1 @@
This file does not match the application import defaults.

View File

@@ -0,0 +1,6 @@
configkit:
imports:
- path: helpers/curated-id.yml
name: Curated summary
messages:
- Summarize the supplied content.

View File

@@ -0,0 +1,3 @@
id: ignored
name: Excluded generated prompt
messages: [This record is excluded.]

View File

@@ -0,0 +1,5 @@
id: welcome
name: Welcome
messages:
- Welcome the user.
resource: welcome.txt

View File

@@ -0,0 +1 @@
id: curated

View File

@@ -0,0 +1,3 @@
id: required
name: Required prompt
messages: [Use the required prompt.]

View File

@@ -0,0 +1 @@
Welcome resource text.

View File

@@ -0,0 +1,7 @@
configkit:
imports:
- path: prompts/required.yml
required: true
destination: [prompts]
placement: sequence-item
prompts: []

View File

@@ -0,0 +1,7 @@
run_label: source-default
configkit:
imports:
- dir: fragments
pattern: '*.yml'
destination: [prompts]
placement: sequence-item

View File

@@ -0,0 +1 @@
Foundation resource text.

View File

@@ -0,0 +1,4 @@
id: welcome
name: Welcome
messages: [Welcome message]
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET

View File

@@ -0,0 +1,3 @@
id: concise
extends: {id: foundation}
name: Concise

View File

@@ -0,0 +1,10 @@
configkit:
sequences:
- destination: [prompts]
key: id
mode: append
prompts:
- id: extra
name: Extra
messages: [Extra message]
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET

View File

@@ -0,0 +1,14 @@
configkit:
sequences:
- destination: [prompts]
key: id
mode: replace
prompts:
- id: replacement
name: Replacement
messages: [Replacement message]
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
- id: last
name: Last
messages: [Last message]
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET

View File

@@ -0,0 +1,14 @@
configkit:
sequences:
- destination: [prompts]
key: id
mode: upsert
prompts:
- id: shared
name: Shared replacement
messages: [Replacement message]
credential: CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET
- id: extra
name: Extra
messages: [Extra message]
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET

View File

@@ -0,0 +1,18 @@
configkit:
sequences:
- destination: [prompts]
key: id
mode: append
prompts:
- id: foundation
name: Foundation
description: Shared foundation instruction
messages: [Foundation message]
resource: foundation.txt
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
- id: shared
name: Shared draft
description: This field must not survive a whole-record replacement
messages: [Draft message]
resource: shared.txt
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET

View File

@@ -0,0 +1,4 @@
version: service-v2
name: example service
metadata:
release: application-2026.10

View File

@@ -0,0 +1,2 @@
version: catalog-v1
endpoint: https://service.example.invalid

View File

@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
func TestFacadeCompatibility(t *testing.T) {
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]{
Definition: configkit.Definition[facadeCompatibilityConfig]{},
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" {
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]{
Definition: configkit.Definition[facadeCompatibilityConfig]{
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {

705
facade_destination_test.go Normal file
View File

@@ -0,0 +1,705 @@
package configkit_test
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
directory := t.TempDir()
hostPath := filepath.Join(directory, "host.yml")
if err := os.WriteFile(hostPath, []byte("value: host\n"), 0o600); err != nil {
t.Fatal(err)
}
native := configkit.NewFilesystem("native", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("value: native\n")},
})
const literalValuePath = "/collections/weather.reporter/profiles/a~1b/~0/0"
destination := configkit.Keys("collections", "weather.reporter", "profiles", "a/b", "~", "0")
for _, test := range []struct {
name string
source configkit.Source
want string
kind configkit.SourceKind
}{
{name: "host file", source: configkit.File(hostPath), want: "host", kind: configkit.SourceKindFile},
{name: "native filesystem", source: native.File("main.yml"), want: "native", kind: configkit.SourceKindFilesystem},
{name: "bytes", source: configkit.Bytes("bytes", []byte("value: bytes\n")), want: "bytes", kind: configkit.SourceKindBytes},
{name: "reader", source: configkit.Reader("reader", strings.NewReader("value: reader\n")), want: "reader", kind: configkit.SourceKindReader},
{name: "mapping", source: configkit.Mapping("mapping", map[string]any{"value": "mapping"}), want: "mapping", kind: configkit.SourceKindMapping},
} {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: destination})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
if got := facadeDestinationValue(t, result.Config, "collections", "weather.reporter", "profiles", "a/b", "~", "0", "value"); got != test.want {
t.Fatalf("placed value = %#v, want %q", got, test.want)
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].Destination.String() != destination.String() || destination.String() != literalValuePath {
t.Fatalf("source placement = %#v, destination = %q", sources, destination.String())
}
})
}
}
func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T) {
for _, test := range []struct {
name string
destination configkit.Path
}{
{name: "omitted destination"},
{name: "explicit empty destination", destination: configkit.Path{}},
} {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("root", []byte("value: root\n")), Destination: test.destination})
if err != nil {
t.Fatalf("root load: %v", err)
}
if result.Config["value"] != "root" || len(result.Sources()) != 1 || result.Sources()[0].Destination.String() != "" {
t.Fatalf("root placement = %#v, sources %#v", result.Config, result.Sources())
}
})
}
emptyPath := configkit.Keys("collections", "empty")
empty, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.Mapping("empty", map[string]any{}), Destination: emptyPath,
})
if err != nil {
t.Fatalf("empty mapping load: %v", err)
}
if value := facadeDestinationMap(t, empty.Config, "collections")["empty"]; value == nil {
t.Fatal("empty mapping did not materialize its destination")
}
if info, ok := empty.Metadata().Lookup(emptyPath); !ok || !info.Explicit {
t.Fatalf("empty destination metadata = %#v, %v", info, ok)
}
absentPath := configkit.Keys("collections", "absent")
absent, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource(), Destination: absentPath})
if err != nil {
t.Fatalf("NoSource with a destination: %v", err)
}
if len(absent.Sources()) != 0 {
t.Fatalf("NoSource created source records: %#v", absent.Sources())
}
if _, ok := absent.Metadata().Lookup(absentPath); ok {
t.Fatal("NoSource materialized its destination")
}
reader := &destinationCountingReader{}
for _, test := range []struct {
name string
source configkit.Source
destination configkit.Path
wantReads int
}{
{name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)},
{name: "reserved configkit key", source: configkit.NoSource(), destination: configkit.Keys("configkit")},
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
} {
t.Run(test.name, func(t *testing.T) {
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: test.destination})
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
t.Fatalf("invalid destination error = %#v", err)
}
if reader.reads != test.wantReads {
t.Fatalf("reader calls = %d, want %d", reader.reads, test.wantReads)
}
})
}
}
func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
fragmentPath := filepath.Join(directory, "fragment.yml")
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
t.Fatal(err)
}
destination := configkit.Keys("collections", "weatherreporter")
result, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: destination,
})
if err != nil {
t.Fatalf("mounted import load: %v", err)
}
collection := facadeDestinationMap(t, result.Config, "collections", "weatherreporter")
if collection["main"] != "present" || collection["imported"] != "present" {
t.Fatalf("mounted imported values = %#v", collection)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
t.Fatalf("mounted import source destinations = %#v", sources)
}
if sources[1].ImportedFrom == nil {
t.Fatalf("imported source lacks its authored directive: %#v", sources[1])
}
}
func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
directory := t.TempDir()
write := func(relative, contents string) string {
t.Helper()
filename := filepath.Join(directory, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return filename
}
mainPath := write("main.yml", `configkit:
imports:
- path: metadata.yml
destination: [metadata]
- glob: prompts/*.yml
destination: [prompts]
- path: entries/main.yml
destination: [profiles]
- path: sibling.yml
destination: [sibling]
root: main
`)
write("metadata.yml", "name: imported\n")
write("prompts/a.yml", "a: {name: alpha}\n")
write("prompts/b.yml", "b: {name: beta}\n")
write("entries/main.yml", `configkit:
imports:
- path: nested/details.yml
destination: [details]
name: profile
`)
write("entries/nested/details.yml", "summary: nested\n")
write("sibling.yml", "name: separate\n")
root, err := configkit.Load(
configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true},
)
if err != nil {
if loadError, ok := err.(*configkit.LoadError); ok {
t.Fatalf("ordinary root Load: %v, diagnostics %#v", err, loadError.Diagnostics())
}
t.Fatalf("ordinary root Load: %v", err)
}
mountedDestination := configkit.Keys("collections", "weatherreporter")
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: mountedDestination,
})
if err != nil {
t.Fatalf("mounted layered Load: %v", err)
}
for _, test := range []struct {
name string
result *configkit.Result[map[string]any]
prefix []string
}{
{name: "ordinary root", result: root},
{name: "mounted entry", result: mounted, prefix: []string{"collections", "weatherreporter"}},
} {
t.Run(test.name, func(t *testing.T) {
at := func(keys ...string) []string {
return append(append([]string(nil), test.prefix...), keys...)
}
if got := facadeDestinationValue(t, test.result.Config, at("root")...); got != "main" {
t.Fatalf("root value = %#v", got)
}
if got := facadeDestinationValue(t, test.result.Config, at("metadata", "name")...); got != "imported" {
t.Fatalf("metadata value = %#v", got)
}
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "a", "name")...); got != "alpha" {
t.Fatalf("first glob value = %#v", got)
}
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "b", "name")...); got != "beta" {
t.Fatalf("second glob value = %#v", got)
}
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "name")...); got != "profile" {
t.Fatalf("entry value = %#v", got)
}
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "details", "summary")...); got != "nested" {
t.Fatalf("nested import value = %#v", got)
}
if got := facadeDestinationValue(t, test.result.Config, at("sibling", "name")...); got != "separate" {
t.Fatalf("subsequent sibling value = %#v", got)
}
wantPaths := []string{
"main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml",
}
wantPhysicalPaths := []string{
mainPath,
filepath.Join(directory, "metadata.yml"),
filepath.Join(directory, "prompts", "a.yml"),
filepath.Join(directory, "prompts", "b.yml"),
filepath.Join(directory, "entries", "main.yml"),
filepath.Join(directory, "entries", "nested", "details.yml"),
filepath.Join(directory, "sibling.yml"),
}
wantImportParents := []string{"", "source-1", "source-1", "source-1", "source-1", "source-5", "source-1"}
wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)}
for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} {
destination := append([]string(nil), test.prefix...)
for _, segment := range suffix {
destination = append(destination, segment)
}
wantDestinations = append(wantDestinations, configkit.Keys(destination...))
}
sources := test.result.Sources()
if len(sources) != len(wantPaths) {
t.Fatalf("sources = %#v, want %d", sources, len(wantPaths))
}
for index, source := range sources {
if filepath.Base(source.Path) != wantPaths[index] || source.Path != wantPhysicalPaths[index] || source.Destination.String() != wantDestinations[index].String() {
t.Errorf("source %d path/destination = (%q, %q), want physical path %q and destination %q", index, source.Path, source.Destination.String(), wantPhysicalPaths[index], wantDestinations[index].String())
}
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFile {
t.Errorf("source %d identity/kind = (%q, %q)", index, source.ID, source.Kind)
}
if wantImportParents[index] == "" {
if source.ImportedFrom != nil {
t.Errorf("entry source unexpectedly has an import location: %#v", source.ImportedFrom)
}
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != wantImportParents[index] || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
t.Errorf("source %d import location = %#v, want declaration in %s", index, source.ImportedFrom, wantImportParents[index])
}
}
})
}
}
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
weatherDestination := configkit.Keys("collections", "weather", "prompts")
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
result, err := configkit.LoadLayered(
configkit.Input{Source: configkit.NoSource()},
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: configkit.Bytes("weather", []byte("summarize.alerts: {title: weather}\n")), Destination: weatherDestination},
{Source: configkit.Bytes("homewatch", []byte("summarize.alerts: {title: home}\n")), Destination: homeDestination},
}}}},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
for _, test := range []struct {
path []string
want string
}{
{path: []string{"collections", "weather", "prompts", "summarize.alerts", "title"}, want: "weather"},
{path: []string{"collections", "homewatch", "prompts", "summarize.alerts", "title"}, want: "home"},
} {
if got := facadeDestinationValue(t, result.Config, test.path...); got != test.want {
t.Errorf("value at %v = %#v, want %q", test.path, got, test.want)
}
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != weatherDestination.String() || sources[1].Destination.String() != homeDestination.String() {
t.Fatalf("independent collection source placements = %#v", sources)
}
}
type destinationCountingReader struct{ reads int }
func (reader *destinationCountingReader) Read([]byte) (int, error) {
reader.reads++
return 0, io.EOF
}
func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
t.Parallel()
filesystem := configkit.NewFilesystem("resources", fstest.MapFS{
"assets/summary.md": &fstest.MapFile{Data: []byte("source-relative summary")},
})
context := &configkit.FilesystemContext{Root: ".", BaseDir: "assets", FS: &filesystem}
destination := configkit.Keys("catalog", "entries")
tokenPath := configkit.Keys("catalog", "entries", "item", "token")
titlePath := configkit.Keys("catalog", "entries", "item", "title")
var callbacks struct{ resolve, validate int }
load := func(token string) *configkit.Result[map[string]any] {
t.Helper()
main := configkit.Input{
Source: configkit.Bytes("main", []byte("item:\n title: main\n resource: summary.md\n token: "+token+"\n")),
Destination: destination, Filesystem: context,
}
underlay := configkit.Input{
Source: configkit.Bytes("underlay", []byte("item:\n title: lower\n retained: lower-value\n token: LOWER-TOKEN-CANARY\n")),
Destination: destination,
}
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[map[string]any]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
Definition: configkit.Definition[map[string]any]{
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
callbacks.resolve++
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
input, ok := resolution.InputInfo(resourcePath)
if !ok || len(input.Origin.Locations) != 1 {
return fmt.Errorf("mounted resource input has no single source origin")
}
source, ok := resolution.Source(input.Origin.Locations[0].SourceID)
if !ok || source.Destination.String() != destination.String() {
return fmt.Errorf("resolution source did not retain its destination")
}
resourceContext, ok := resolution.SourceContext(source.ID)
if !ok {
return fmt.Errorf("resolution source context is unavailable")
}
data, err := fs.ReadFile(resourceContext.FS, path.Join(resourceContext.BaseDir, "summary.md"))
if err != nil {
return fmt.Errorf("read source-relative resource: %w", err)
}
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
entry["resource_text"] = string(data)
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
return nil
},
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
},
})
if err != nil {
t.Fatalf("mounted load: %v", err)
}
return result
}
first := load("FIRST-TOKEN-CANARY")
second := load("SECOND-TOKEN-CANARY")
if callbacks.resolve != 2 || callbacks.validate != 2 {
t.Fatalf("lifecycle callbacks = %#v", callbacks)
}
for _, result := range []*configkit.Result[map[string]any]{first, second} {
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, result.Config, "catalog"), "entries"), "item")
if entry["title"] != "main" || entry["retained"] != "lower-value" || entry["resource_text"] != "source-relative summary" {
t.Fatalf("mounted main/underlay configuration = %#v", entry)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
t.Fatalf("public source destinations = %#v", sources)
}
resource, ok := result.SourceContext(sources[0].ID)
if !ok || resource.BaseDir != "assets" {
t.Fatalf("completed source context = %#v, %v", resource, ok)
}
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, "summary.md"))
if err != nil || string(data) != "source-relative summary" {
t.Fatalf("completed relative resource = %q, %v", data, err)
}
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
}
if history := result.Metadata().History(titlePath); len(history) < 2 {
t.Fatalf("mounted title history = %#v", history)
}
if explanation, ok := result.Explain(tokenPath); !ok || !explanation.Effective.Redacted {
t.Fatalf("mounted secret explanation = %#v, %v", explanation, ok)
}
var effective, explanation bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&explanation, tokenPath); err != nil {
t.Fatal(err)
}
output := effective.String() + explanation.String()
for _, canary := range []string{"FIRST-TOKEN-CANARY", "SECOND-TOKEN-CANARY", "LOWER-TOKEN-CANARY"} {
if strings.Contains(output, canary) {
t.Fatalf("public inspection leaked %q: %s", canary, output)
}
}
}
// Mutating published records and query copies must not alter the retained result.
returnedSources := first.Sources()
returnedSources[0].Destination = configkit.Keys("mutated")
if got := first.Sources()[0].Destination.String(); got != destination.String() {
t.Fatalf("source destination changed after caller mutation: %q", got)
}
info, ok := first.Metadata().Lookup(titlePath)
if !ok {
t.Fatal("mounted metadata lookup failed")
}
info.Origin.Locations[0].SourceID = "mutated"
if fresh, ok := first.Metadata().Lookup(titlePath); !ok || fresh.Origin.Locations[0].SourceID == "mutated" {
t.Fatalf("metadata copy mutation escaped: %#v, %v", fresh, ok)
}
history := first.Metadata().History(titlePath)
if len(history) == 0 {
t.Fatal("mounted history is empty")
}
history[0].Path = configkit.Keys("mutated")
if fresh := first.Metadata().History(titlePath); len(fresh) == 0 || fresh[0].Path.String() != titlePath.String() {
t.Fatalf("history copy mutation escaped: %#v", fresh)
}
differences, err := configkit.Diff(first, second)
if err != nil {
t.Fatal(err)
}
if len(differences) != 1 || differences[0].Path.String() != tokenPath.String() ||
!differences[0].Left.Redacted || !differences[0].Right.Redacted ||
strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "TOKEN-CANARY") {
t.Fatalf("public mounted secret diff = %#v", differences)
}
if callbacks.resolve != 2 || callbacks.validate != 2 {
t.Fatalf("inspection reran lifecycle callbacks: %#v", callbacks)
}
}
func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
type configuration struct {
Tenant struct {
Count int `yaml:"count"`
} `yaml:"tenant"`
}
destination := configkit.Keys("tenant")
_, err := configkit.LoadLayered[configuration](configkit.Input{
Source: configkit.Bytes("invalid", []byte("count: not-an-integer\n")), Destination: destination,
})
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("failed load error = %T %v", err, err)
}
sources := loadError.Sources()
if len(sources) != 1 || sources[0].Destination.String() != destination.String() {
t.Fatalf("failed source destinations = %#v", sources)
}
sources[0].Destination = configkit.Keys("caller-change")
if fresh := loadError.Sources(); len(fresh) != 1 || fresh[0].Destination.String() != destination.String() {
t.Fatalf("failed source destination was not detached: %#v", fresh)
}
}
func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testing.T) {
directory := t.TempDir()
write := func(name, contents string) string {
t.Helper()
filename := filepath.Join(directory, name)
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return filename
}
t.Run("imported parse failure retains effective source destination", func(t *testing.T) {
mainPath := write("parse/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
childPath := write("parse/child.yml", "count: [unterminated\n")
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
t.Fatalf("imported parse diagnostic = %#v", diagnostics)
}
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
t.Fatalf("imported parse failure sources = %#v", sources)
}
})
t.Run("directive failure stays source local", func(t *testing.T) {
mainPath := write("directive/main.yml", "configkit:\n imports:\n - path: parent.yml\n destination: [tenant]\n")
parentPath := write("directive/parent.yml", "configkit:\n imports:\n - path: details.yml\n destination: [3]\n")
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid || !reflect.DeepEqual(diagnostics[0].Path, wantPath) || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
t.Fatalf("import directive diagnostic = %#v", diagnostics)
}
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != parentPath {
t.Fatalf("directive failure sources = %#v", sources)
}
})
t.Run("composition failure reports effective path and all sources", func(t *testing.T) {
mainPath := write("compose/main.yml", "configkit:\n imports:\n - path: left.yml\n destination: [tenant]\n - path: right.yml\n destination: [tenant]\n")
write("compose/left.yml", "value: same\n")
write("compose/right.yml", "value: same\n")
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeComposeConflict || diagnostics[0].Path.String() != "/mount/tenant/value" {
t.Fatalf("composition diagnostic = %#v", diagnostics)
}
sources := loadError.Sources()
if len(sources) != 3 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[2].Destination.String() != "/mount/tenant" {
t.Fatalf("composition failure sources = %#v", sources)
}
})
t.Run("version failure reports effective path and imported source", func(t *testing.T) {
mainPath := write("version/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
childPath := write("version/child.yml", "version: forbidden\n")
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
}, configkit.LayeredOptions[map[string]any]{
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("mount", "tenant", "version"),
Check: func(configkit.Version, *configkit.Validation) error { return nil },
}},
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportVersion || diagnostics[0].Path.String() != "/mount/tenant/version" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
t.Fatalf("version diagnostic = %#v", diagnostics)
}
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
t.Fatalf("version failure sources = %#v", sources)
}
})
t.Run("typed validation reports effective path and authored location", func(t *testing.T) {
type configuration struct {
Mount struct {
Tenant struct {
Count int `yaml:"count"`
} `yaml:"tenant"`
} `yaml:"mount"`
}
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
childPath := write("typed/child.yml", "count: invalid\n")
_, err := configkit.LoadLayered[configuration](configkit.Input{
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeDecodeType || diagnostics[0].Path.String() != "/mount/tenant/count" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || diagnostics[0].Primary.Line != 1 {
t.Fatalf("typed validation diagnostic = %#v", diagnostics)
}
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
t.Fatalf("typed validation sources = %#v", sources)
}
})
}
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
t.Parallel()
cycle := map[string]any{}
cycle["self"] = cycle
for _, fixture := range []struct {
name string
values map[string]any
path func(configkit.Path) configkit.Path
}{
{
name: "unsupported nested sequence value",
values: map[string]any{"a/b": []any{map[string]any{"~": func() {}}}},
path: func(base configkit.Path) configkit.Path { return base.Key("a/b").Index(0).Key("~") },
},
{
name: "cyclic value",
values: map[string]any{"item": cycle},
path: func(base configkit.Path) configkit.Path { return base.Key("item").Key("self") },
},
{
name: "constructor failure keeps source coordinates",
path: func(configkit.Path) configkit.Path { return configkit.Path{} },
},
} {
t.Run(fixture.name, func(t *testing.T) {
for _, role := range []string{"root", "mounted main", "mounted underlay"} {
t.Run(role, func(t *testing.T) {
var destination configkit.Path
if role != "root" {
destination = configkit.Keys("catalog", "entries")
}
wantPath := fixture.path(destination)
input := configkit.Input{Source: configkit.Mapping("invalid mapping", fixture.values), Destination: destination}
var result *configkit.Result[map[string]any]
var err error
if role == "mounted underlay" {
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
})
} else {
result, err = configkit.LoadLayered[map[string]any](input)
}
var loadError *configkit.LoadError
if result != nil || !errors.As(err, &loadError) {
t.Fatalf("failed load = %#v, %v", result, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
diagnostic := diagnostics[0]
if diagnostic.Code != configkit.CodeSourceInvalid || !reflect.DeepEqual(diagnostic.Path, wantPath) {
t.Fatalf("diagnostic = %#v; want source.invalid at %q", diagnostic, wantPath.String())
}
if diagnostic.Primary == nil || *diagnostic.Primary != (configkit.Location{SourceID: "source-1"}) || len(diagnostic.Related) != 0 {
t.Fatalf("mapping source locations = %#v, %#v", diagnostic.Primary, diagnostic.Related)
}
sources := loadError.Sources()
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Destination.String() != destination.String() {
t.Fatalf("error sources = %#v", sources)
}
})
}
})
}
}
func facadeDestinationMap(t *testing.T, value map[string]any, keys ...string) map[string]any {
t.Helper()
for _, key := range keys {
child, ok := value[key].(map[string]any)
if !ok {
t.Fatalf("%q = %#v, want mapping", key, value[key])
}
value = child
}
return value
}
func facadeDestinationValue(t *testing.T, value map[string]any, keys ...string) any {
t.Helper()
return facadeDestinationMap(t, value, keys[:len(keys)-1]...)[keys[len(keys)-1]]
}

316
facade_discovery_test.go Normal file
View File

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

View File

@@ -23,6 +23,9 @@ type equivalentConfig struct {
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
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")
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
}{
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
{"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},
{"mapping", configkit.Mapping("programmatic", map[string]any{
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
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) {

View File

@@ -0,0 +1,249 @@
package configkit_test
import (
"fmt"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
for _, phase := range []string{"defaults", "resolution"} {
for _, component := range []any{"application-data", nil} {
for _, duplicate := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%v/duplicate=%t", phase, component, duplicate), func(t *testing.T) {
items := []any{map[string]any{"extends": component}}
if duplicate {
items = append(items, map[string]any{"extends": component})
}
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "extends")},
Defaults: func() map[string]any {
if phase == "defaults" {
return map[string]any{"items": items}
}
return map[string]any{}
},
}
if phase == "resolution" {
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload")
return nil
}
}
loaded, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
if duplicate {
loadErr := requireFacadeLoadError(t, err)
diagnostics := loadErr.Diagnostics()
if loaded != nil || len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != "/items" {
t.Fatalf("duplicate effective payload diagnostics = %#v", diagnostics)
}
return
}
if err != nil || loaded == nil {
t.Fatalf("ordinary effective identity was interpreted as a directive: %v", err)
}
value := loaded.Config["items"].([]any)[0].(map[string]any)
if actual, present := value["extends"]; !present || actual != component {
t.Fatalf("effective payload = %#v", value)
}
})
}
}
}
}
func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
identity := configkit.SequenceIdentityAt(configkit.Keys("items"), "id")
for _, source := range []string{"defaults", "CLI", "resolution"} {
for _, shape := range []string{"duplicate", "missing", "non-scalar"} {
t.Run(source+"/"+shape, func(t *testing.T) {
items := finalIdentityRecords(shape)
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{identity},
Defaults: func() map[string]any {
if source == "defaults" {
return map[string]any{"items": items}
}
return map[string]any{}
},
}
options := configkit.Options[map[string]any]{Definition: definition}
switch source {
case "CLI":
options.Overrides = []configkit.Override{{Path: configkit.Keys("items"), Value: items, Flag: "--items"}}
case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records")
return nil
}
}
result, err := configkit.Load(configkit.NoSource(), options)
if result != nil {
t.Fatalf("invalid final identities returned a result: %#v", result.Config)
}
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) != 1 {
t.Fatalf("diagnostics = %#v", loadErr.Diagnostics())
}
diagnostic := loadErr.Diagnostics()[0]
wantCode, wantPath := configkit.CodeSequenceInvalid, "/items/0/id"
if shape == "duplicate" {
wantCode, wantPath = configkit.CodeSequenceConflict, "/items"
}
if diagnostic.Code != wantCode || diagnostic.Path.String() != wantPath {
t.Fatalf("diagnostic = %#v, want %s at %s", diagnostic, wantCode, wantPath)
}
if source == "CLI" && (diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1") {
t.Fatalf("CLI identity diagnostic lost its source: %#v", diagnostic)
}
})
}
}
}
func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
selector := configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("items"),
}
for _, source := range []string{"defaults", "CLI", "resolution"} {
t.Run(source, func(t *testing.T) {
name := map[string]string{"defaults": "team", "CLI": "cloud", "resolution": "orbit"}[source]
path := configkit.Keys("collections", name, "items")
items := finalIdentityRecords("duplicate")
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(selector, "id"),
},
Defaults: func() map[string]any {
if source == "defaults" {
return map[string]any{"collections": map[string]any{name: map[string]any{"items": items}}}
}
return map[string]any{}
},
}
options := configkit.Options[map[string]any]{Definition: definition}
switch source {
case "CLI":
options.Overrides = []configkit.Override{{Path: path, Value: items, Flag: "--items"}}
case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records")
return nil
}
}
result, err := configkit.Load(configkit.NoSource(), options)
if result != nil {
t.Fatalf("invalid dynamic identity returned a result: %#v", result.Config)
}
loadErr := requireFacadeLoadError(t, err)
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != path.String() {
t.Fatalf("dynamic identity diagnostics = %#v", diagnostics)
}
})
}
}
func TestFinalSequenceIdentityAppliesToLayeredLoads(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Defaults: func() map[string]any {
return map[string]any{"items": finalIdentityRecords("duplicate")}
},
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
if result != nil {
t.Fatalf("invalid layered final identities returned a result: %#v", result.Config)
}
loadErr := requireFacadeLoadError(t, err)
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
t.Fatalf("layered identity diagnostics = %#v", diagnostics)
}
}
func TestFinalSequenceIdentityUsesTypedNumbersAndIgnoresUnmatchedShapes(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
configkit.SequenceIdentityAt(configkit.Keys("unmatched"), "id"),
},
}
_, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{
Definition: configkit.Definition[map[string]any]{
SequenceIdentities: definition.SequenceIdentities,
Defaults: func() map[string]any {
return map[string]any{"items": []any{
map[string]any{"id": int64(1)}, map[string]any{"id": float64(1)},
}}
},
},
})
loadErr := requireFacadeLoadError(t, err)
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
t.Fatalf("typed numeric identity diagnostics = %#v", diagnostics)
}
valid, err := configkit.Load(configkit.Mapping("valid", map[string]any{
"items": []any{
map[string]any{"id": nil}, map[string]any{"id": true},
map[string]any{"id": int64(1)}, map[string]any{"id": "one"},
},
"other": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
}), configkit.Options[map[string]any]{Definition: definition})
if err != nil || valid == nil {
t.Fatalf("valid scalar identities and unmatched duplicate sequence = %#v, %v", valid, err)
}
if _, err := configkit.Load(configkit.Mapping("other-shapes", map[string]any{"items": nil}), configkit.Options[map[string]any]{Definition: definition}); err != nil {
t.Fatalf("null at a registered path was validated as an identity sequence: %v", err)
}
}
func TestFinalSequenceIdentityDiagnosticDoesNotRevealResolvedSecrets(t *testing.T) {
const canary = "FINAL-IDENTITY-CREDENTIAL-CANARY"
const secretName = "CONFIGKIT_FINAL_IDENTITY_SECRET"
t.Setenv(secretName, canary)
type item struct {
ID string `yaml:"id"`
Token configkit.Secret `yaml:"token"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Defaults: func() config {
return config{Items: []item{
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
}}
},
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[config]{Definition: definition})
if result != nil {
t.Fatalf("duplicate identity returned a result: %#v", result.Config)
}
if err == nil || strings.Contains(err.Error(), canary) {
t.Fatalf("identity diagnostic leaked a resolved secret: %v", err)
}
loadErr := requireFacadeLoadError(t, err)
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
t.Fatalf("secret-bearing identity diagnostics = %#v", diagnostics)
}
}
func finalIdentityRecords(shape string) []any {
switch shape {
case "duplicate":
return []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}}
case "missing":
return []any{map[string]any{"other": "value"}}
default:
return []any{map[string]any{"id": map[string]any{"nested": "value"}}}
}
}

View File

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

View File

@@ -0,0 +1,99 @@
package configkit_test
import (
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
root := t.TempDir()
for name, contents := range map[string]string{
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
"data/helper.yml": "owner: target\nresource: resource.txt\n",
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
"data/resource.txt": "target resource",
"aliases/resource.txt": "alias resource",
} {
filename := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
for name, target := range map[string]string{"aliases/item.yml": "../data/item.yml", "linked": "data"} {
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
filesystem := configkit.NewFilesystem("aliases", os.DirFS(root))
type configuration struct {
Value string `yaml:"value"`
Owner string `yaml:"owner"`
Resource string `yaml:"resource"`
}
for _, test := range []struct {
name, declaration, item, helper, owner, resource string
}{
{"file/path", "path: aliases/item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
{"file/dir", "dir: aliases\n pattern: item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
{"file/glob", "glob: 'aliases/ite*.yml'", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
{"directory/path", "path: linked/item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
{"directory/dir", "dir: linked\n pattern: item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
{"directory/glob", "glob: 'lin*/ite*.yml'", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
} {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
})
if err != nil {
t.Fatal(err)
}
if result.Config.Owner != test.owner || result.Config.Value != "item" {
t.Fatalf("configuration = %#v", result.Config)
}
sources := result.Sources()
if len(sources) != 3 || sources[1].Path != test.item || sources[2].Path != test.helper {
t.Fatalf("source identities = %#v", sources)
}
for field, source := range map[string]configkit.SourceInfo{"value": sources[1], "resource": sources[2]} {
info, ok := result.Metadata().Lookup(configkit.Keys(field))
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != source.ID {
t.Fatalf("%s origin = %#v", field, info)
}
context, ok := result.SourceContext(source.ID)
if !ok || context.BaseDir != path.Dir(source.Path) {
t.Fatalf("%s context = %#v", field, context)
}
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Resource))
if err != nil || string(contents) != test.resource {
t.Fatalf("%s resource = %q, %v", field, contents, err)
}
}
})
}
// The same native path is still a repeat when separately included. An alias
// to a leaf file remains a different native namespace path, as for path imports.
for _, selector := range []string{"path: aliases/item.yml", "dir: aliases\n pattern: item.yml", "glob: 'aliases/ite*.yml'"} {
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
t.Fatalf("repeated source: %v", err)
}
_, err = configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
})
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
t.Fatalf("distinct native alias paths should reach composition: %v", err)
}
}
}

View File

@@ -0,0 +1,299 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
for _, test := range []struct {
depth string
valid bool
}{
{"999999999999999999999999.5", false},
{"999999999999999999999999e1", false},
{"999999999999999999999999", true},
{"-0", true},
} {
t.Run(test.depth, func(t *testing.T) {
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfigkit:\n imports:\n - dir: absent\n recursive: true\n depth: "+test.depth+"\n")
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
if test.valid {
if err != nil || result.Config["value"] != true {
t.Fatalf("valid depth: %v", err)
}
return
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
t.Fatalf("invalid depth accepted: %v", err)
}
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
}
})
}
}
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
type config struct {
Main string `yaml:"main"`
Transitive string `yaml:"transitive"`
Exception string `yaml:"exception"`
Underlay string `yaml:"underlay"`
IgnoredDeep string `yaml:"ignored_deep"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: parent/parent.yml
main: main
`)
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
imports:
- dir: generated
- dir: explicit
pattern: '*.yaml'
recursive: false
`)
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
imports:
- dir: underlay-files
`)
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
recursive := true
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
t.Fatalf("configuration = %#v", loaded.Config)
}
paths := make(map[string]bool)
for _, source := range loaded.Sources() {
paths[source.Path] = true
}
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
if !paths[canonicalFacadeImportPath(t, selected)] {
t.Errorf("selected source %q missing from source records", selected)
}
}
}
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
imports:
- dir: snippets
`)
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
if err != nil || unrestricted.Config.Value != "imported" {
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
}
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
if err != nil || empty.Config.Value != "" {
t.Fatalf("empty pattern = %#v, %v", empty, err)
}
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 {
t.Fatalf("empty-pattern evidence = %#v", emptySources)
}
required := true
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("required empty selection error = %T %v", err, err)
}
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
}
}
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:
- dir: parts
`)
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
recursive := true
patterns := []string{"*.cfg"}
definition := Definition[config]{Version: &VersionRule{
Path: Keys("version"),
Check: func(version Version, _ *Validation) error {
if version != (Version{Present: true, Text: "v1"}) {
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)
}
})
}
}
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
for _, layered := range []bool{false, true} {
input := strings.NewReader("value: read\n")
underlayInput := strings.NewReader("value: underlay\n")
defaultsCalled := false
resolveCalled := false
definition := Definition[config]{
Defaults: func() config { defaultsCalled = true; return config{} },
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
}
defaults := ImportDefaults{Pattern: []string{"["}}
var err error
if layered {
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
Definition: definition, ImportDefaults: defaults,
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
})
} else {
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
if loadError != nil {
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
}
t.Fatalf("layered=%v error = %T %v", layered, err, err)
}
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
}
}
}
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
imports:
- dir: absent
value: loaded
`)
callbackPath := ""
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
first, ok := resolution.Source("source-1")
if !ok || len(first.ImportSelections) != 1 {
t.Fatalf("resolution source = %#v, %v", first, ok)
}
first.ImportSelections[0].Path = Keys("callback mutation")
second, ok := resolution.Source("source-1")
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
}
callbackPath = second.ImportSelections[0].Path.String()
return nil
}}})
if err != nil {
t.Fatal(err)
}
if callbackPath != "/configkit/imports/0" {
t.Fatalf("callback path = %q", callbackPath)
}
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 {
t.Fatalf("result source evidence = %#v", sources)
}
sources[0].ImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("result snapshot changed after mutation: %q", got)
}
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
imports:
- dir: absent
- path: required-missing.yml
required: true
`)
_, err = Load(File(failurePath), Options[config]{Imports: true})
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("failed load error = %T %v", err, err)
}
errorSources := loadError.Sources()
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("error source evidence = %#v", errorSources)
}
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("error snapshot changed after mutation: %q", got)
}
}
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
t.Helper()
filename := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return filename
}
func canonicalFacadeImportPath(t *testing.T, filename string) string {
t.Helper()
canonical, err := filepath.EvalSymlinks(filename)
if err != nil {
t.Fatal(err)
}
return filepath.ToSlash(canonical)
}

View File

@@ -0,0 +1,135 @@
package configkit
import (
"errors"
"io/fs"
"path"
"strings"
"testing"
"testing/fstest"
)
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
filesystem := NewFilesystem("bundle", fstest.MapFS{
"bundle/config/main.yml": {Data: []byte(`configkit:
imports:
- dir: records
pattern: '*-item.yml'
destination: [services, records]
placement: sequence-item
services:
records:
- id: authored
version: authored
`)},
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
imports:
- path: helper.yml
version: ` + canary + `
entries:
parent:
resource: icon.txt
child:
extends: parent
`)},
"bundle/config/records/helper.yml": {Data: []byte("id: helper-supplied\n")},
"bundle/config/records/icon.txt": {Data: []byte("icon payload")},
})
result, err := Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
Imports: true,
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
Secret: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version"
},
},
SourceVersionMode: SourceVersionModeIfPresent,
})
if err != nil {
var loadError *LoadError
if errors.As(err, &loadError) {
t.Fatalf("load discovered item: %v; diagnostics: %#v", err, loadError.Diagnostics())
}
t.Fatalf("load discovered item: %v", err)
}
services := result.Config["services"].(map[string]any)
records := services["records"].([]any)
if len(records) != 2 || records[0].(map[string]any)["id"] != "helper-supplied" || records[0].(map[string]any)["version"] != nil || records[1].(map[string]any)["version"] != "authored" {
t.Fatalf("nested records = %#v", records)
}
childPath := Keys("services", "records").Index(0).Key("entries").Key("child").Key("resource")
child := records[0].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
if child["resource"] != "icon.txt" {
t.Fatalf("inherited relative resource = %#v", child)
}
sources := result.Sources()
filesByID := make(map[string]string, len(sources))
for _, source := range sources {
filesByID[source.ID] = source.Path
}
var itemSource SourceInfo
for _, source := range sources {
if source.Path == "bundle/config/records/a-item.yml" {
itemSource = source
}
}
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
}
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" {
t.Fatalf("assigned helper ID origin = %#v", idInfo)
}
resourceInfo, ok := result.Metadata().Lookup(childPath)
if !ok || len(resourceInfo.Origin.Locations) != 1 || filesByID[resourceInfo.Origin.Locations[0].SourceID] != "bundle/config/records/a-item.yml" {
t.Fatalf("inherited resource metadata = %#v", resourceInfo)
}
explanation, ok := result.Explain(childPath)
if !ok || explanation.Effective.YAML != "icon.txt\n" || len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != resourceInfo.Origin.Locations[0].SourceID {
t.Fatalf("inherited resource explanation = %#v", explanation)
}
context, ok := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
if !ok || context.FS == nil || context.BaseDir != "config/records" {
t.Fatalf("inherited resource context = %#v, %v", context, ok)
}
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child["resource"].(string)))
if err != nil || string(contents) != "icon payload" {
t.Fatalf("inherited resource read = %q, %v", contents, err)
}
_, err = Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
Imports: true,
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
Secret: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version"
},
},
SourceVersionMode: SourceVersionModeIfPresent,
})
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid {
t.Fatalf("consumed header supplied an identity: %v", err)
}
if strings.Contains(err.Error(), canary) {
t.Fatalf("diagnostic exposed the consumed secret header: %v", err)
}
var failedItemSource SourceInfo
foundFailedItem := false
for _, source := range loadError.Sources() {
if source.Path == "bundle/config/records/a-item.yml" {
failedItemSource = source
foundFailedItem = true
break
}
}
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
}
}

119
facade_import_root_test.go Normal file
View File

@@ -0,0 +1,119 @@
package configkit_test
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
parts := filepath.Join(root, "real", "parts")
if err := os.MkdirAll(parts, 0o700); err != nil {
t.Fatal(err)
}
partPath := filepath.Join(parts, "part.yml")
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for _, follow := range []bool{true, false} {
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
mainPath := filepath.Join(root, "main.yml")
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
if err != nil {
t.Fatal(err)
}
if result.Config["value"] != "included" {
t.Fatalf("configuration = %#v", result.Config)
}
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
t.Fatalf("canonical imported source = %#v", sources)
}
})
}
}
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
root := t.TempDir()
parts := filepath.Join(root, "nested/root/parts")
if err := os.MkdirAll(parts, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(parts, "value.yml"), []byte("value: 1\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("parts", filepath.Join(root, "nested/root/linked")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
filesystem := configkit.NewFilesystem("nested", os.DirFS(root))
context := &configkit.FilesystemContext{FS: &filesystem, Root: "nested/root", BaseDir: "nested/root"}
for _, selector := range []string{
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
} {
t.Run(selector, func(t *testing.T) {
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
if err != nil {
t.Fatal(err)
}
if result.Config["value"] != int64(1) {
t.Fatalf("configuration = %#v", result.Config)
}
})
}
if err := os.WriteFile(filepath.Join(root, "nested/outside.yml"), []byte("value: outside\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../../outside.yml", filepath.Join(parts, "escape.yml")); err != nil {
t.Fatal(err)
}
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
t.Fatalf("escaping %s: %v", selector, err)
}
}
}
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
for _, native := range []bool{false, true} {
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
if native {
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
}
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
if err != nil {
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
}
if result.Config["value"] != int64(1) {
t.Fatalf("configuration = %#v", result.Config)
}
}
}
}

View File

@@ -91,6 +91,214 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
}
}
func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testing.T) {
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET"
const canary = "IDENTIFIED-INHERITANCE-SECRET-CANARY"
t.Setenv(secretName, canary)
type step struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type job struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Credential configkit.Secret `yaml:"credential"`
Resource string `yaml:"resource"`
Steps []step `yaml:"steps"`
Settings map[string]map[string]string `yaml:"settings"`
}
type configuration struct {
Jobs []job `yaml:"jobs"`
}
input := []byte(`jobs:
- id: child
extends: {id: parent}
name: child-name
- id: parent
extends: {id: base}
- id: base
name: base-name
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET
resource: icon.txt
steps:
- id: base-step
value: base-step-value
- id: second-step
value: second-step-value
settings:
primary: {value: base-setting}
secondary: {extends: primary}
`)
filesystem := configkit.NewFilesystem("identified-bundle", fstest.MapFS{
"config/identified.yml": &fstest.MapFile{Data: input},
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
})
registrations := []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
}
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{
SequenceIdentities: registrations,
Secret: func(path configkit.Path) bool {
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
},
},
})
if err != nil {
if loadError, ok := err.(*configkit.LoadError); ok {
t.Fatalf("load identified inheritance: %v (%#v)", err, loadError.Diagnostics())
}
t.Fatal(err)
}
if len(result.Config.Jobs) != 3 || result.Config.Jobs[0].ID != "child" || result.Config.Jobs[1].ID != "parent" || result.Config.Jobs[2].ID != "base" {
t.Fatalf("inherited job positions and identities = %#v", result.Config.Jobs)
}
child := result.Config.Jobs[0]
if child.Name != "child-name" || child.Credential.Value() != canary || child.Resource != "icon.txt" || len(child.Steps) != 2 {
t.Fatalf("inherited child = %#v", child)
}
if got := []string{child.Steps[0].ID, child.Steps[1].ID}; strings.Join(got, ",") != "base-step,second-step" {
t.Fatalf("inherited nested steps = %v", got)
}
if child.Steps[0].Value != "base-step-value" || child.Steps[1].Value != "second-step-value" || child.Settings["secondary"]["value"] != "base-setting" {
t.Fatalf("inherited nested collection and mapping = %#v", child)
}
credentialPath := configkit.Keys("jobs").Index(0).Key("credential")
credentialInfo, found := result.Metadata().Lookup(credentialPath)
if !found || len(credentialInfo.Origin.Inheritance) != 2 {
t.Fatalf("inherited secret provenance = %#v, %v", credentialInfo, found)
}
resourceInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("resource"))
if !found || len(resourceInfo.Origin.Inheritance) != 2 || len(resourceInfo.Origin.Locations) != 1 {
t.Fatalf("inherited resource provenance = %#v, %v", resourceInfo, found)
}
context, found := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
if !found || context.BaseDir != "config" || context.FS == nil {
t.Fatalf("inherited resource context = %#v, %v", context, found)
}
resource, readErr := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
if readErr != nil || string(resource) != "identified-parent-resource" {
t.Fatalf("inherited resource = %q, %v", resource, readErr)
}
resourceHistory, found := result.Explain(configkit.Keys("jobs").Index(0).Key("resource"))
if !found || len(resourceHistory.History) == 0 {
t.Fatalf("inherited resource history = %#v, %v", resourceHistory, found)
}
credentialHistory, found := result.Explain(credentialPath)
if !found || !credentialHistory.Effective.Redacted {
t.Fatalf("inherited secret explanation = %#v, %v", credentialHistory, found)
}
identityInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("id"))
if !found || !identityInfo.Explicit || len(identityInfo.Origin.Inheritance) != 0 ||
len(identityInfo.Origin.Locations) != 1 {
t.Fatalf("child identity origin and presence = %#v, %v", identityInfo, found)
}
if _, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("extends")); found {
t.Fatal("identified record relationship remained in effective metadata")
}
var rendered bytes.Buffer
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || strings.Contains(rendered.String(), "extends:") {
t.Fatalf("effective inherited configuration = %q, %v", rendered.String(), err)
}
}
func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.T) {
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET"
const canary = "IDENTIFIED-INHERITANCE-FAILURE-CANARY"
t.Setenv(secretName, canary)
type job struct {
ID string `yaml:"id"`
Credential configkit.Secret `yaml:"credential"`
}
type configuration struct {
Jobs []job `yaml:"jobs"`
}
input := []byte(`jobs:
- id: child
extends: {id: missing}
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET
`)
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
},
})
if err == nil || strings.Contains(err.Error(), canary) {
t.Fatalf("identified inheritance failure = %v; secret canary must remain undisclosed", err)
}
}
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
policy := func(mode string) string {
return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
}
tests := []struct {
name string
lower string
higher string
wantCode configkit.Code
wantValue string
wantSuccess bool
}{
{
name: "upserted parent supplies its final payload",
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
higher: policy("upsert") + "jobs: [{id: parent, value: higher}, {id: child, extends: {id: parent}}]\n",
wantValue: "higher", wantSuccess: true,
},
{
name: "replacement removes a lower parent before lookup",
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
higher: policy("replace") + "jobs: [{id: child, extends: {id: parent}}]\n",
wantCode: configkit.CodeInheritanceMissing,
},
{
name: "upserted child without a directive replaces the invalid lower child",
lower: policy("upsert") + "jobs: [{id: child, extends: {id: missing}, value: lower}]\n",
higher: policy("upsert") + "jobs: [{id: child, value: higher}]\n",
wantValue: "higher", wantSuccess: true,
},
{
name: "self-reference cannot resolve through a lower predecessor",
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
higher: policy("upsert") + "jobs: [{id: parent, extends: {id: parent}, value: higher}]\n",
wantCode: configkit.CodeInheritanceCycle,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main.yml", []byte(test.higher))}, configkit.LayeredOptions[map[string]any]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower.yml", []byte(test.lower))}}}},
})
if test.wantSuccess {
if err != nil {
t.Fatal(err)
}
jobs, ok := result.Config["jobs"].([]any)
if !ok || len(jobs) == 0 {
t.Fatalf("assembled jobs = %#v", result.Config["jobs"])
}
child, ok := jobs[len(jobs)-1].(map[string]any)
if !ok || child["value"] != test.wantValue {
t.Fatalf("surviving record = %#v, want inherited value %q", jobs[len(jobs)-1], test.wantValue)
}
return
}
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("load error = %v, want code %s", err, test.wantCode)
}
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Code == test.wantCode {
return
}
}
t.Fatalf("load diagnostics = %#v, want code %s", loadError.Diagnostics(), test.wantCode)
})
}
}
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
@@ -206,27 +414,24 @@ func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
}
}
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
})
if err != nil || result.Config.Entries["child"].Count != 2 {
t.Fatalf("import and CLI reference = %#v, %v", result, err)
}
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("superseded reference history = %#v", history)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOverrideDirective {
t.Fatalf("CLI inheritance relationship change = %v", err)
}
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
})
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
loadError, ok := err.(*configkit.LoadError)
loadError, ok = err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
t.Fatalf("strict conflict before expansion = %v", err)
}

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
package configkit_test
import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
for _, test := range []struct {
name, item, child, helper string
underlay, mainItem bool
}{
{name: "item", item: "id: item\nversion: domain\n"},
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
{name: "nested", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n"},
{name: "underlay", item: "id: item\nversion: domain\n", underlay: true},
{name: "main-item", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n", mainItem: true},
} {
t.Run(test.name, func(t *testing.T) {
files := fstest.MapFS{
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
"item.yml": {Data: []byte(test.item)},
"child.yml": {Data: []byte(test.child)},
"helper.yml": {Data: []byte(test.helper)},
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
}
filesystem := configkit.NewFilesystem("items", files)
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
}}
var result *configkit.Result[map[string]any]
var err error
switch {
case test.underlay:
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"version": "1"})}, configkit.LayeredOptions[map[string]any]{
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: filesystem.File("underlay.yml"), Imports: true}}}},
})
case test.mainItem:
result, err = configkit.Load(filesystem.File("item.yml"), configkit.Options[map[string]any]{
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, Definition: definition,
})
default:
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
}
if err != nil {
t.Fatal(err)
}
item := result.Config["items"].([]any)[0].(map[string]any)
fieldPath := configkit.Keys("items").Index(0).Key("version")
if test.child != "" {
item = item["children"].([]any)[0].(map[string]any)
fieldPath = configkit.Keys("items").Index(0).Key("children").Index(0).Key("version")
}
if item["version"] != "domain" {
t.Fatalf("domain version = %#v", item)
}
if info, ok := result.Metadata().Lookup(fieldPath); !ok || !info.Explicit {
t.Fatalf("version metadata = %#v, %v", info, ok)
}
})
}
}

View File

@@ -2,10 +2,12 @@ package configkit_test
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
@@ -219,3 +221,77 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
t.Fatal("CLI source returned a context")
}
}
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
borrowed := &observedResourceFS{files: fstest.MapFS{
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
}}
namespace := configkit.NewFilesystem("bundle", borrowed)
input := configkit.Input{
Source: namespace.File("bundle/config/main.yml"), Imports: true,
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
}
load := func() *configkit.Result[map[string]any] {
t.Helper()
result, err := configkit.LoadLayered[map[string]any](input)
if err != nil {
t.Fatalf("load relative native imports: %v", err)
}
return result
}
result := load()
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
sources := result.Sources()
if len(sources) != len(wantPaths) {
t.Fatalf("sources = %#v", sources)
}
for index, source := range sources {
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
t.Errorf("source %d = %#v", index, source)
}
if index == 0 {
if source.ImportedFrom != nil {
t.Errorf("entry source has import location %#v", source.ImportedFrom)
}
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
}
}
for _, test := range []struct {
field, effectivePath, wantBase, wantResource, wantContents string
}{
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
} {
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
if !ok || len(info.Origin.Locations) != 1 {
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
}
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
}
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
if err != nil || string(contents) != test.wantContents {
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
}
}
sources[1].Destination = configkit.Keys("caller", "mutation")
for query := 0; query < 2; query++ {
fresh := result.Sources()
if fresh[1].Destination.String() != "/logical" {
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
}
}
second := load()
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
}
}

View File

@@ -0,0 +1,240 @@
package configkit_test
import (
"errors"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {
fields := []string{"namespace", "id", "id"}
selector := configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}
identity := configkit.SequenceIdentityMatching(selector, fields...)
selector[1] = configkit.SequenceLiteralKey("other")
fields[0] = "changed"
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}}
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}},
}})
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}},
}})
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
})
if err != nil {
t.Fatalf("load after mutating constructor inputs: %v", err)
}
prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
if len(prompts) != 2 {
t.Fatalf("registered compound identity did not append records: %#v", prompts)
}
}
func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) {
dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id")
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"),
dynamic,
}}
underlay := configkit.Mapping("underlay", map[string]any{
"prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}},
"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}},
},
})
main := configkit.Mapping("main", map[string]any{
"prompts": []any{map[string]any{"namespace": "beta", "id": "same"}},
"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}},
},
})
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
})
if err != nil {
t.Fatalf("load mixed exact and dynamic registrations: %v", err)
}
rootRecords := loaded.Config["prompts"].([]any)
if len(rootRecords) != 2 {
t.Fatalf("compound root identities = %#v", rootRecords)
}
collections := loaded.Config["collections"].(map[string]any)
for _, name := range []string{"team", "cloud"} {
prompts := collections[name].(map[string]any)["prompts"].([]any)
if len(prompts) != 2 {
t.Errorf("dynamic prompts for %q = %#v", name, prompts)
}
}
}
func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) {
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceLiteralKey("*"),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
}}
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
"*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}},
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
}})
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
"*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}},
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
}})
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
})
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics())
}
wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
}}
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
Definition: wildcard,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
})
if err != nil {
t.Fatalf("wildcard registration did not match mapping keys: %v", err)
}
for name, raw := range loaded.Config["collections"].(map[string]any) {
if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 {
t.Errorf("wildcard result for %q has %d records", name, got)
}
}
}
func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) {
tests := []struct {
name string
identity configkit.SequenceIdentity
wantPath string
}{
{
name: "index path",
identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"),
wantPath: "/records/0",
},
{
name: "empty fields",
identity: configkit.SequenceIdentityAt(configkit.Keys("records")),
wantPath: "/records",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
reader := strings.NewReader("records: []\n")
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}},
})
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath {
t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics())
}
if reader.Len() == 0 {
t.Fatal("source was read before registration validation")
}
})
}
}
func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) {
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()}
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(selector, "id"),
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"),
}, "slug"),
}}
reader := strings.NewReader("collections: {}\n")
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition})
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics())
}
if reader.Len() == 0 {
t.Fatal("source was read before overlapping registrations were rejected")
}
}
func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) {
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("entries"),
configkit.SequenceLiteralKey("child"),
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
}}
loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs:
entries:
parent:
collections:
team:
prompts: [{id: inherited, text: base}]
child:
extends: parent
collections:
team:
prompts: [{id: added, text: child}]
`)), configkit.Options[map[string]any]{Definition: definition})
if err != nil {
t.Fatalf("load with dynamic inherited destination: %v", err)
}
child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any)
prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
if len(prompts) != 2 {
t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts)
}
}
func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) {
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(),
}, "id"),
}}
for _, source := range []string{"first", "second"} {
loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{
source: []any{map[string]any{"id": "reused"}},
}}), configkit.Options[map[string]any]{Definition: definition})
if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 {
t.Fatalf("independent load %q = %#v, %v", source, loaded, err)
}
}
}
func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError {
t.Helper()
var loadErr *configkit.LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error = %v, want *LoadError", err)
}
return loadErr
}

699
facade_sequence_test.go Normal file
View File

@@ -0,0 +1,699 @@
package configkit_test
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
t.Parallel()
for _, defaults := range []bool{false, true} {
for _, registered := range []bool{false, true} {
definition := configkit.Definition[map[string]any]{}
if defaults {
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
}
if registered {
definition.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}
}
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
if err != nil {
t.Fatal(err)
}
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(ordinary.Config, layered.Config) {
t.Fatal("NoSource values differ between entry points")
}
for _, result := range []*configkit.Result[map[string]any]{ordinary, layered} {
if len(result.Sources()) != 0 || result.Metadata().IsExplicit(configkit.Keys()) {
t.Fatal("NoSource contributed an explicit root")
}
info, ok := result.Metadata().Lookup(configkit.Keys())
if !ok || info.Origin.Layer != configkit.LayerDefault {
t.Fatalf("NoSource root origin = %#v, %t", info, ok)
}
}
empty, err := configkit.Load(configkit.Mapping("empty", map[string]any{}), configkit.Options[map[string]any]{Definition: definition})
if err != nil || !empty.Metadata().IsExplicit(configkit.Keys()) {
t.Fatal("real empty mapping lost its presence")
}
}
}
}
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
t.Parallel()
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
"one.yml": {Data: []byte("id: one\nconfigkit: {imports: [{path: helper-one.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n")},
"two.yml": {Data: []byte("id: two\nconfigkit: {imports: [{path: helper-two.yml}]}\n")},
"helper-one.yml": {Data: []byte("text: first helper\n")},
"helper-two.yml": {Data: []byte("text: second helper\n")},
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
"child-helper.yml": {Data: []byte("text: nested helper\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
})
if err != nil {
t.Fatal(err)
}
sourceFiles := make(map[string]string)
for _, source := range result.Sources() {
sourceFiles[source.ID] = source.Path
}
for _, test := range []struct {
path configkit.Path
file, text string
}{
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
} {
explanation, ok := result.Explain(test.path)
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
t.Fatalf("helper history at %s = %#v", test.path.String(), explanation)
}
locations := explanation.History[0].Origin.Locations
if len(locations) != 1 || sourceFiles[locations[0].SourceID] != test.file {
t.Fatalf("helper origin at %s = %#v", test.path.String(), locations)
}
info, ok := result.Metadata().Lookup(test.path)
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != locations[0].SourceID {
t.Fatal("metadata and explanation disagree")
}
}
}
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
upper []any
path configkit.Path
}{
{"shorter", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(1).Key("id")},
{"empty", []any{}, configkit.Keys("records").Index(0).Key("id")},
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
} {
t.Run(test.name, func(t *testing.T) {
mainYAML := "configkit:\n sequences:\n - destination: [records]\n key: id\n mode: replace\n"
if len(test.upper) == 0 {
mainYAML += "records: []\n"
} else {
mainYAML += "records: [{id: main}]\n"
}
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}},
})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(test.path)
if !ok || explanation.Effective.Present || len(explanation.History) < 2 {
t.Fatalf("removal not explained: %#v", explanation)
}
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 {
t.Fatalf("wrong removal transition/origin: %#v", last)
}
})
}
}
func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
t.Parallel()
filesystem := configkit.NewFilesystem("conflicts", fstest.MapFS{
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: good.yml, destination: [records], placement: sequence-item}, {path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
"group.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
"nested.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: parent.yml, destination: [records], placement: sequence-item}]}\n")},
"parent.yml": {Data: []byte("id: parent\nchildren: [{id: authored-child}]\nconfigkit: {imports: [{path: bad.yml, destination: [children], placement: sequence-item}]}\n")},
"good.yml": {Data: []byte("id: good\n")},
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
"helper.yml": {Data: []byte("id: duplicate-field\n")},
})
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}
for _, test := range []struct {
name, file, path string
grouped, itemMain bool
}{
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
{"item main source", "bad.yml", "/records/0/id", false, true},
{"grouped item source", "bad.yml", "/records/1/id", true, true},
} {
t.Run(test.name, func(t *testing.T) {
var err error
input := configkit.Input{Source: filesystem.File(test.file), Imports: true}
if test.itemMain {
input.Destination = configkit.Keys("records")
input.PlacementMode = configkit.PlacementModeSequenceItem
}
if test.grouped {
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: configkit.Mapping("prior", map[string]any{"records": []any{map[string]any{"id": "prior"}}})},
input,
}}},
})
} else {
_, err = configkit.Load(input.Source, configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: input.Destination, PlacementMode: input.PlacementMode})
}
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) != 1 {
t.Fatalf("expected helper conflict: %v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != test.path {
t.Fatalf("diagnostic = %s %s, want %s", diagnostic.Code, diagnostic.Path.String(), test.path)
}
files := make(map[string]string)
for _, source := range loadError.Sources() {
files[source.ID] = source.Path
}
locations := append([]configkit.Location(nil), diagnostic.Related...)
if diagnostic.Primary != nil {
locations = append(locations, *diagnostic.Primary)
}
gotFiles := make(map[string]bool, len(locations))
for _, location := range locations {
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
}
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
}
})
}
}
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
}
ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{
Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
})
if err != nil {
t.Fatalf("ordinary item load: %v", err)
}
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
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 {
t.Fatalf("ordinary item source = %#v", sources)
}
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: configkit.Mapping("first", map[string]any{"id": "first"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
{Source: configkit.Mapping("second", map[string]any{"id": "second"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
}}},
})
if err != nil {
t.Fatalf("layered item load: %v", err)
}
items := layered.Config["entries"].([]any)
if len(items) != 2 || items[0].(map[string]any)["id"] != "first" || items[1].(map[string]any)["id"] != "second" {
t.Fatalf("layered item placement = %#v", layered.Config)
}
for _, source := range layered.Sources() {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
t.Errorf("layered item source = %#v", source)
}
}
precedence, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{
"entries": []any{map[string]any{"id": "main"}},
})}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Mapping("lower", map[string]any{"id": "lower"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
}}}},
})
if err != nil {
t.Fatalf("registered sequence precedence load: %v", err)
}
if got := precedence.Config["entries"].([]any); len(got) != 2 || got[0].(map[string]any)["id"] != "lower" || got[1].(map[string]any)["id"] != "main" {
t.Fatalf("registered upsert did not retain the lower sequence: %#v", precedence.Config)
}
for _, test := range []struct {
name string
load func(configkit.Source) error
}{
{name: "ordinary", load: func(source configkit.Source) error {
_, loadErr := configkit.Load(source, configkit.Options[map[string]any]{PlacementMode: configkit.PlacementMode(9)})
return loadErr
}},
{name: "layered", load: func(source configkit.Source) error {
_, loadErr := configkit.LoadLayered[map[string]any](configkit.Input{Source: source, PlacementMode: configkit.PlacementMode(9)})
return loadErr
}},
} {
t.Run(test.name, func(t *testing.T) {
reader := &destinationCountingReader{}
err := test.load(configkit.Reader("unread", reader))
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || reader.reads != 0 {
t.Fatalf("invalid placement result = %v, reads = %d", err, reader.reads)
}
})
}
}
func TestFacadeItemImportGlobComposesWithAuthoredSequence(t *testing.T) {
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
if err := os.WriteFile(mainPath, []byte(`configkit:
imports:
- glob: records/*.yml
destination: [items]
placement: sequence-item
items:
- id: authored
name: authored item
`), 0o600); err != nil {
t.Fatal(err)
}
for name, id := range map[string]string{"b.yml": "imported-b", "a.yml": "imported-a"} {
if err := os.MkdirAll(filepath.Join(directory, "records"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "records", name), []byte("id: "+id+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
var callbackSources []configkit.SourceInfo
var callbackContexts []configkit.ResourceContext
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
Imports: true,
Definition: configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
for _, id := range []string{"source-2", "source-3"} {
source, ok := resolution.Source(id)
if !ok {
t.Errorf("resolver could not query %s", id)
continue
}
context, found := resolution.SourceContext(id)
if !found || context.FS == nil || context.BaseDir != "records" {
t.Errorf("resolver context for %s = %#v, %v", id, context, found)
}
callbackSources = append(callbackSources, source)
callbackContexts = append(callbackContexts, context)
}
return nil
},
},
})
if err != nil {
t.Fatalf("load item glob: %v", err)
}
items := result.Config["items"].([]any)
wantIDs := []string{"imported-a", "imported-b", "authored"}
for index, item := range items {
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
}
}
if len(callbackSources) != 2 || len(callbackContexts) != 2 {
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
}
for index, source := range callbackSources {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
t.Errorf("callback source = %#v", source)
}
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
t.Errorf("callback context = %#v", callbackContexts[index])
}
}
metadata := result.Metadata()
for index := 0; index < 2; index++ {
path := configkit.Keys("items").Index(index).Key("id")
info, ok := metadata.Lookup(path)
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
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 {
t.Fatalf("final item sources = %#v", got)
}
}
func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
helperPath := filepath.Join(directory, "helper.yml")
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: main\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
})
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("early failure = %v", err)
}
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
sources[1].PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("early source snapshots = %#v", sources)
}
if err := os.WriteFile(helperPath, []byte("value: invalid-for-typed-schema\n"), 0o600); err != nil {
t.Fatal(err)
}
type item struct {
ID string `yaml:"id"`
Metadata struct {
Value int `yaml:"value"`
} `yaml:"metadata"`
}
type configuration struct {
Items []item `yaml:"items"`
}
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[configuration]{
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
})
loadError, ok = err.(*configkit.LoadError)
if !ok {
t.Fatalf("post-placement failure = %v", err)
}
sources = loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
t.Fatalf("post-placement source snapshots = %#v", sources)
}
firstSnapshot := sources[1]
sources[1].Destination = configkit.Keys("mutated")
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
t.Fatalf("failure source snapshots were aliased: %#v", again)
}
var callback configkit.SourceInfo
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
callback, _ = resolution.Source("source-2")
return nil
}},
})
if err != nil {
t.Fatalf("successful item load: %v", err)
}
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("callback source snapshot = %#v", callback)
}
}
func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
childPath := filepath.Join(directory, "child.yml")
helperPath := filepath.Join(directory, "helper.yml")
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(childPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: child\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
Imports: true, Destination: configkit.Keys("groups"), PlacementMode: configkit.PlacementModeSequenceItem,
})
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("nested acquisition failure = %v", err)
}
sources := loadError.Sources()
if len(sources) != 3 {
t.Fatalf("nested source snapshots = %#v", sources)
}
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
t.Errorf("outer item snapshot = %#v", got)
}
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
t.Errorf("nested item fallback = %#v", got)
}
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
t.Errorf("nested helper fallback = %#v", got)
}
}
func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
fields := []string{"id"}
path := configkit.Keys("records")
registration := configkit.SequenceIdentityAt(path, fields...)
fields[0] = "changed"
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{registration},
Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(configkit.Version, *configkit.Validation) error {
path = configkit.Keys("other")
return nil
},
},
}
_, err := configkit.Load(configkit.Mapping("registration", map[string]any{
"version": "1",
"records": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
}), configkit.Options[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("duplicate registered identity = %v", err)
}
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Code == configkit.CodeComposeConflict {
return
}
}
t.Fatalf("registration mutation changed the in-flight policy: %#v", loadError.Diagnostics())
}
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
zero := configkit.SourceInfo{}
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
t.Fatalf("zero source info = %#v", zero)
}
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
if err != nil {
t.Fatal(err)
}
sources := loaded.Sources()
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
t.Fatalf("mapping source evidence = %#v", sources)
}
first := sources[0]
first.Destination = configkit.Keys("caller")
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
t.Fatalf("source evidence changed after caller mutation: %#v", again)
}
}
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
}
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("lower", []byte("entries: [{id: lower}]\n")),
}}}},
})
if err != nil {
t.Fatal(err)
}
for index, id := range []string{"lower", "main"} {
path := configkit.Keys("entries").Index(index).Key("id")
explanation, ok := result.Explain(path)
if !ok || !explanation.Effective.Present || explanation.Effective.YAML != id+"\n" || len(explanation.History) == 0 {
t.Fatalf("tier history at %s = %#v, %v", path.String(), explanation, ok)
}
if explanation.History[len(explanation.History)-1].After.YAML != id+"\n" {
t.Fatalf("tier history at %s does not retain final record: %#v", path.String(), explanation.History)
}
}
}
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
type record struct {
ID string `yaml:"id"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Empty string `yaml:"empty"`
Nullable *string `yaml:"nullable"`
Token string `yaml:"token"`
Omitted string `yaml:"omitted"`
}
type configuration struct {
Entries []record `yaml:"entries"`
}
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
underlayPath := filepath.Join(directory, "underlay.yml")
if err := os.WriteFile(mainPath, []byte(`configkit:
sequences:
- destination: [entries]
key: id
mode: replace
entries:
- id: current
count: 0
enabled: false
empty: ""
nullable: null
token: CURRENT-SECRET-CANARY
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(underlayPath, []byte(`configkit:
imports:
- glob: records/*.yml
destination: [entries]
placement: sequence-item
`), 0o600); err != nil {
t.Fatal(err)
}
recordsDir := filepath.Join(directory, "records")
if err := os.MkdirAll(recordsDir, 0o700); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"a.yml": "id: old-a\ntoken: OLD-SECRET-CANARY-A\n",
"b.yml": "id: old-b\ntoken: OLD-SECRET-CANARY-B\n",
} {
if err := os.WriteFile(filepath.Join(recordsDir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
var callbackItems []configkit.SourceInfo
var callbackContexts []configkit.ResourceContext
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
Definition: configkit.Definition[configuration]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
Secret: func(path configkit.Path) bool {
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
},
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
for _, field := range []string{"count", "enabled", "empty", "nullable"} {
path := configkit.Keys("entries").Index(0).Key(field)
if !resolution.IsExplicit(path) {
t.Errorf("resolver did not see explicit %s", path.String())
}
}
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
t.Error("resolver treated an omitted field as explicit")
}
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count"))
if !found || info.Origin.Layer != configkit.LayerBase {
t.Errorf("resolver current-record input info = %#v, %v", info, found)
}
for _, id := range []string{"source-3", "source-4"} {
source, ok := resolution.Source(id)
if !ok {
t.Errorf("resolver could not query %s", id)
continue
}
context, contextOK := resolution.SourceContext(id)
if !contextOK || context.FS == nil || context.BaseDir != "records" {
t.Errorf("resolver context for %s = %#v, %v", id, context, contextOK)
}
callbackItems = append(callbackItems, source)
callbackContexts = append(callbackContexts, context)
}
return nil
},
},
})
if err != nil {
if loadError, ok := err.(*configkit.LoadError); ok {
t.Fatalf("layered item replacement: %#v", loadError.Diagnostics())
}
t.Fatalf("layered item replacement: %v", err)
}
if len(result.Config.Entries) != 1 || result.Config.Entries[0].ID != "current" || result.Config.Entries[0].Count != 0 ||
result.Config.Entries[0].Enabled || result.Config.Entries[0].Empty != "" || result.Config.Entries[0].Nullable != nil {
t.Fatalf("typed replacement result = %#v", result.Config)
}
if len(callbackItems) != 2 || len(callbackContexts) != 2 {
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
}
for _, source := range callbackItems {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
t.Errorf("callback item source = %#v", source)
}
}
metadata := result.Metadata()
for _, field := range []string{"count", "enabled", "empty", "nullable"} {
path := configkit.Keys("entries").Index(0).Key(field)
if info, ok := metadata.Lookup(path); !ok || !info.Explicit || (field == "nullable") != info.Null {
t.Errorf("explicit metadata at %s = %#v, %v", path.String(), info, ok)
}
}
if info, ok := metadata.Lookup(configkit.Keys("entries").Index(0).Key("omitted")); ok && info.Explicit {
t.Errorf("metadata marked an omitted zero-valued field explicit: %#v", info)
}
mainID := ""
for _, source := range result.Sources() {
if source.Path == mainPath {
mainID = source.ID
}
}
currentOrigin, ok := metadata.Lookup(configkit.Keys("entries").Index(0).Key("id"))
if !ok || len(currentOrigin.Origin.Locations) != 1 || currentOrigin.Origin.Locations[0].SourceID != mainID || mainID == "" {
t.Fatalf("replacement origin = %#v, main source %q", currentOrigin, mainID)
}
oldRecord, ok := result.Explain(configkit.Keys("entries").Index(1).Key("token"))
if !ok || oldRecord.Effective.Present {
t.Fatalf("removed appended item explanation = %#v, %v", oldRecord, ok)
}
maskedHistory := false
for _, change := range oldRecord.History {
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
}
if !maskedHistory {
t.Fatal("removed appended item lost historical sensitivity")
}
var effective bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
var explanation bytes.Buffer
if err := result.WriteExplanation(&explanation, configkit.Keys("entries").Index(1).Key("token")); err != nil {
t.Fatal(err)
}
for _, output := range []string{effective.String(), explanation.String()} {
for _, canary := range []string{"CURRENT-SECRET-CANARY", "OLD-SECRET-CANARY-A", "OLD-SECRET-CANARY-B"} {
if strings.Contains(output, canary) {
t.Fatalf("sensitive sequence value leaked: %s", output)
}
}
}
}

View File

@@ -0,0 +1,197 @@
package configkit_test
import (
"fmt"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type movedVersionItem struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
}
type movedVersionCatalog struct {
Items []movedVersionItem `yaml:"items"`
}
func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
t.Parallel()
for _, mounted := range []bool{false, true} {
for _, matching := range []bool{false, true} {
for _, version := range []string{"4", "'4'"} {
t.Run(fmt.Sprintf("mounted=%t/matched=%t/%s", mounted, matching, version), func(t *testing.T) {
body := "items: [{id: main, version: " + version + "}]\n"
path := configkit.Keys("items").Index(0).Key("version")
main := configkit.Input{}
if mounted {
body, path = "id: main\nversion: "+version+"\n", configkit.Keys("version")
main.Destination, main.PlacementMode = configkit.Keys("items"), configkit.PlacementModeSequenceItem
}
main.Source = configkit.Bytes("main", []byte(body))
keep := "{id: keep}"
if mounted {
keep = "{id: keep, version: ordinary}"
}
lower := "items: [" + keep + "]\n"
if matching {
lower = "items: [" + keep + ", {id: main}]\n"
}
calls := 0
loaded, err := configkit.LoadLayered(main, configkit.LayeredOptions[movedVersionCatalog]{
SourceVersionMode: configkit.SourceVersionModeIfPresent,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(lower))}}}},
Definition: configkit.Definition[movedVersionCatalog]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Version: &configkit.VersionRule{Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
calls++
if !version.Present || version.Text != "4" {
t.Errorf("checked version = %#v", version)
}
return nil
}},
},
})
if err != nil {
t.Fatal(err)
}
if calls != 1 || len(loaded.Config.Items) != 2 || loaded.Config.Items[1].ID != "main" || loaded.Config.Items[1].Version != "4" {
t.Fatalf("moved main = %#v, checker calls = %d", loaded.Config, calls)
}
if mounted && loaded.Config.Items[0].Version != "ordinary" {
t.Fatal("unrelated underlay payload was treated as the main declaration")
}
for _, source := range loaded.Sources() {
if source.Layer == configkit.LayerBase && source.VersionCheck.Path.String() != path.String() {
t.Fatal("effective movement changed source-local version evidence")
}
}
})
}
}
}
}
func TestOverlayVersionProtectionUsesAssignedRecordPositions(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, payload string
code configkit.Code
}{
{name: "change main header", payload: "[{id: main, version: changed}]", code: configkit.CodeOverlayVersion},
{name: "remove main header", payload: "[{id: main}]", code: configkit.CodeOverlayVersion},
{name: "other record version", payload: "[{id: keep, version: ordinary}]"},
{name: "new record version", payload: "[{id: new, version: ordinary}]"},
{name: "only main header gets integer conversion", payload: "[{id: new, version: 5}]", code: configkit.CodeDecodeType},
} {
t.Run(test.name, func(t *testing.T) {
files := configkit.NewFilesystem("catalog", fstest.MapFS{
"overlays/test.yml": {Data: []byte("items: " + test.payload + "\n")},
})
calls := 0
loaded, err := configkit.LoadLayered(configkit.Input{
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
}, configkit.LayeredOptions[movedVersionCatalog]{
SourceVersionMode: configkit.SourceVersionModeIfPresent,
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{FS: &files, BaseDir: ".", Root: "."},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte("items: [{id: keep}]\n"))}}}},
Definition: configkit.Definition[movedVersionCatalog]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
},
})
if calls != 1 {
t.Fatalf("checker calls = %d", calls)
}
if test.code != "" {
loadErr := requireFacadeLoadError(t, err)
if loaded != nil || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != test.code {
t.Fatalf("version diagnostics = %#v", loadErr.Diagnostics())
}
return
}
if err != nil {
t.Fatal(err)
}
if loaded.Config.Items[1].Version != "4" {
t.Fatal("unrelated overlay changed main header")
}
})
}
}
func TestUnderlayVersionProtectionUsesMainRecordPosition(t *testing.T) {
t.Parallel()
for _, imported := range []bool{false, true} {
t.Run(fmt.Sprintf("imported=%t", imported), func(t *testing.T) {
payload := "items: [{id: keep}, {id: main, version: forbidden}]\n"
lower := configkit.Input{Source: configkit.Bytes("lower", []byte(payload))}
want := configkit.CodeUnderlayVersion
if imported {
files := configkit.NewFilesystem("lower", fstest.MapFS{
"main.yml": {Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
"fragment.yml": {Data: []byte(payload)},
})
lower.Source, lower.Imports = files.File("main.yml"), true
want = configkit.CodeImportVersion
}
calls := 0
result, err := configkit.LoadLayered(configkit.Input{
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")),
Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
}, configkit.LayeredOptions[movedVersionCatalog]{
SourceVersionMode: configkit.SourceVersionModeIfPresent,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{lower}}},
Definition: configkit.Definition[movedVersionCatalog]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
},
})
failure := requireFacadeLoadError(t, err)
if result != nil || calls != 1 || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != want || failure.Diagnostics()[0].Path.String() != "/items/1/version" {
t.Fatalf("result=%#v calls=%d diagnostics=%#v", result, calls, failure.Diagnostics())
}
})
}
}
func TestMainVersionFollowsInheritedSequenceMovement(t *testing.T) {
t.Parallel()
for _, parentID := range []string{"keep", "main"} {
t.Run(parentID, func(t *testing.T) {
body := "base: {items: [{id: " + parentID + ", version: ordinary}]}\nchild:\n extends: base\n items: [{id: main, version: 4}]\n"
type config struct {
Base, Child movedVersionCatalog
}
calls := 0
result, err := configkit.Load(configkit.Bytes("main", []byte(body)), configkit.Options[config]{
SourceVersionMode: configkit.SourceVersionModeIfPresent,
Definition: configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("base", "items"), "id"),
configkit.SequenceIdentityAt(configkit.Keys("child", "items"), "id"),
},
Version: &configkit.VersionRule{Path: configkit.Keys("child", "items").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
},
})
if calls != 1 {
t.Fatalf("checker calls = %d", calls)
}
if parentID == "main" {
failure := requireFacadeLoadError(t, err)
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeInheritanceVersion {
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
return
}
if err != nil {
t.Fatal(err)
}
if len(result.Config.Child.Items) != 2 || result.Config.Child.Items[1].Version != "4" || result.Config.Child.Items[0].Version != "ordinary" {
t.Fatalf("inherited items = %#v", result.Config.Child.Items)
}
})
}
}

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