241 Commits

Author SHA1 Message Date
191015e289 Prepare release notes for ConfigKit 1.1.0 2026-11-03 02:25:47 +00:00
7e53863adb Delegate release file checks to ReleaseKit 2026-11-03 01:32:05 +00:00
f7044cf695 Explain source-aware path normalization inside lists 2026-11-03 01:27:33 +00:00
c2b3cb32da Document field-level resolution and matched sequence history 2026-11-03 01:01:25 +00:00
43ac34d10e Adopt ReleaseKit 0.8 and document publication recovery 2026-11-03 00:55:19 +00:00
795cc2f379 Reuse cached pointers when ordering resolution changes 2026-11-02 17:40:12 +00:00
e878ce51ff Index collection prefixes for path correspondence 2026-11-02 17:36:55 +00:00
ebf98ffc62 Propagate matched sensitivity with one work queue 2026-11-02 17:33:44 +00:00
34c3241dae Report moved history at final record paths 2026-11-02 17:29:36 +00:00
25382a112b Preserve matched history for removed fields 2026-11-02 17:24:45 +00:00
95495ae7fc Record sequence resolution efficiency findings 2026-11-02 16:42:14 +00:00
27be6bab23 Record sequence resolution review findings 2026-11-02 16:36:30 +00:00
b066090db4 Verify sequence resolution through the public API 2026-11-02 15:00:48 +00:00
1d8438b635 Protect paired history snapshots from disclosure 2026-11-02 14:58:52 +00:00
3b10f97424 Carry sensitivity across matched records 2026-11-02 14:56:38 +00:00
c296eba6c1 Explain matched resolution and removed records 2026-11-02 14:54:47 +00:00
0799e1d18f Replay matched record input history at entry positions 2026-11-02 14:52:05 +00:00
151b56e7ec Select history using matched entry positions 2026-11-02 14:49:32 +00:00
4549fbaeef Preserve matched record metadata after resolution 2026-11-02 14:47:52 +00:00
c61597d137 Retain matched resolution evidence in history 2026-11-02 14:46:13 +00:00
d120fe907b Audit registered collection changes by identity 2026-11-02 14:43:48 +00:00
7f004f67ad Audit ordinary list changes at field paths 2026-11-02 14:41:42 +00:00
c0a0c753f9 Match registered collection endpoints for resolution 2026-11-02 14:40:04 +00:00
40b00da175 Record sequence resolution correspondence decision 2026-11-02 14:37:22 +00:00
1cee127300 Plan field-level sequence resolution and history 2026-11-02 14:32:15 +00:00
9adcf14966 Clarify history for removed records and reused positions 2026-11-02 14:20:58 +00:00
6b7a960d11 Define identity-aware resolution history in the sequence roadmap 2026-11-02 13:09:03 +00:00
128bf475c3 Record the proposal for field-level list resolution 2026-11-02 12:42:25 +00:00
c88aac990b Select Go 1.27 for the first stable release 2026-11-02 01:15:23 +00:00
48c50e49d7 Write the version 1.0.0 release notes 2026-11-02 01:05:49 +00:00
7ed4629c8d Remove completed configuration control plans 2026-11-02 01:02:13 +00:00
c63f4ff687 Complete the document-root control migration review 2026-11-02 00:46:56 +00:00
68ec552b52 Document source-root configuration controls 2026-11-02 00:45:36 +00:00
e5475ea7f8 Update consumer examples for document-root controls 2026-11-02 00:44:33 +00:00
cf9e2fd677 Exercise document-root controls through the public API 2026-11-02 00:44:04 +00:00
eb87a10e1c Migrate structural and inspection fixtures 2026-11-02 00:42:41 +00:00
54bdc24865 Migrate acquisition fixtures to document-root controls 2026-11-02 00:40:16 +00:00
ad5b859337 Check source versions against original document controls 2026-11-02 00:37:45 +00:00
1fd0adc571 Allow configuration names in application paths 2026-11-02 00:35:37 +00:00
5b03ca6278 Scope configuration controls to source roots 2026-11-02 00:33:15 +00:00
b9f41d698e Record document-root configuration control decision 2026-11-02 00:30:59 +00:00
fb8d56cfef Plan the document-root config migration 2026-11-02 00:28:02 +00:00
b27fe0598a Allow ordinary config data at the application root 2026-11-02 00:19:10 +00:00
74bff9704e Document document-root config controls 2026-11-02 00:16:32 +00:00
1e69abc88c Adopt ReleaseKit and add hosted release delivery 2026-11-02 00:07:18 +00:00
6c60f02b5a Update numbering within the future roadmap 2026-11-01 23:59:31 +00:00
d211537873 Remove completed roadmap documents 2026-11-01 19:05:56 +00:00
6db441893d Make consumer documentation easier to follow 2026-11-01 19:04:48 +00:00
e74c26a4b3 Catalog readability improvements for consumer documentation 2026-11-01 18:36:52 +00:00
08bb3a20c6 Share secret traversal between resolution and validation 2026-11-01 18:23:50 +00:00
b3bb57eda0 Record shared abstraction audit findings 2026-11-01 18:02:15 +00:00
94d1e333d4 Correct discovery documentation and test commands 2026-11-01 17:53:39 +00:00
ff10d03978 Record documentation audit findings 2026-11-01 17:48:52 +00:00
b4477a236a Update copyright statement to remove unnecessary language 2026-11-01 14:34:04 +00:00
d818b08556 Record successful remote vanity verification 2026-11-01 14:24:36 +00:00
587f407a32 Migrate ConfigKit to its vanity module path 2026-11-01 14:19:37 +00:00
335bd7bc31 Settle the Forge publication endpoint 2026-11-01 14:04:15 +00:00
331195202e Document the vanity module migration roadmap 2026-11-01 14:02:28 +00:00
21c9bc0ad0 Retire completed roadmaps before the next release 2026-10-27 19:15:40 +00:00
68400ffdfb Fix source lookup tests with symlinked temporary directories 2026-10-27 18:42:19 +00:00
5d5d51ab11 Record the public API cleanup review 2026-10-27 16:25:33 +00:00
69bd9ead53 Complete the public API cleanup roadmap 2026-10-27 15:44:00 +00:00
2ff588eb64 Update guidance for the cleaned public API 2026-10-27 15:40:22 +00:00
c96197c417 Add detached source lookup to results and errors 2026-10-27 15:26:19 +00:00
24ab623cb2 Rename the retained history limit diagnostic 2026-10-27 15:18:57 +00:00
1ce147c9f6 Use main for the main provenance layer 2026-10-27 15:15:39 +00:00
9833ca3aa3 Replace generic resolution recording with intent methods 2026-10-27 15:12:38 +00:00
eafbd32489 Clarify search and secret bootstrap names 2026-10-27 15:09:04 +00:00
9ac0053590 Clarify import default field names 2026-10-27 15:07:15 +00:00
791cf16136 Simplify empty import selection evidence 2026-10-27 15:05:22 +00:00
31ae30372e Clarify source version evidence names 2026-10-27 15:01:54 +00:00
7ee9e92ef5 Clarify resolution metadata and sensitivity names 2026-10-27 15:00:04 +00:00
556b10b2cd Plan the public API cleanup 2026-10-27 14:55:22 +00:00
07e4ae5d92 Expand accepted API decisions into an implementation roadmap 2026-10-27 14:43:08 +00:00
7548fa101a Accept descriptive import default field names 2026-10-27 14:35:42 +00:00
197567177e Include failed-load source lookup in A1 2026-10-27 14:31:25 +00:00
7330107389 Include obsolete recording diagnostic removal in D6 2026-10-27 14:29:35 +00:00
ad6d3748d9 Accept history terminology for the retained evidence limit 2026-10-27 14:27:56 +00:00
1ee508ba02 Accept main terminology for provenance 2026-10-27 14:26:54 +00:00
e9155fced6 Accept the discovery path constructor rename 2026-10-27 14:25:18 +00:00
14c83a6515 Record additional public API review opportunities 2026-10-27 14:21:01 +00:00
3ebb848ead Accept direct source lookup on results 2026-10-27 14:10:51 +00:00
4289f9ff7f Record accepted public API cleanup decisions 2026-10-27 14:06:45 +00:00
a050986916 Catalog the public API for version one 2026-10-27 13:34:26 +00:00
fb71421956 Prepare sensitivity dependencies once per recorded operation 2026-10-27 03:18:55 +00:00
b281adb751 Index explanation selections once per query 2026-10-27 03:15:01 +00:00
1448f9b19d Deduplicate parsed sensitivity paths correctly 2026-10-27 03:12:21 +00:00
731149fc6d Localize history selection to affected paths 2026-10-27 03:10:07 +00:00
9ff873f54c Index stable history trees during normalization 2026-10-27 03:08:34 +00:00
3f389662e9 Keep explanation origins scoped to the query 2026-10-27 03:05:15 +00:00
906ca34b6d Avoid duplicate intermediate source history 2026-10-27 03:04:31 +00:00
17216a40f3 Preserve final dynamic secret tags in inspection 2026-10-27 03:02:17 +00:00
896c1cde08 Record additional history scaling findings 2026-10-27 03:00:14 +00:00
91769e9f9b Record interface remediation review findings 2026-10-27 02:52:47 +00:00
98c8b7c842 Complete interface contract remediation 2026-10-26 18:23:58 +00:00
1954052631 Preserve composition conflict claimant order 2026-10-26 18:17:04 +00:00
3ffaef7ede Remove superseded history attribution paths 2026-10-26 18:11:04 +00:00
6a1447c327 Reconcile retained history accounting 2026-10-26 17:46:31 +00:00
12fd0a7191 Publish inspection through one frozen history model 2026-10-26 17:36:36 +00:00
8155465322 Move sensitivity classification to shared history 2026-10-26 17:25:11 +00:00
408dd13c24 Integrate history prefixes into load handoffs 2026-10-26 17:15:30 +00:00
738c8a9cc7 Replay explanations through shared history selections 2026-10-26 17:01:32 +00:00
4b4e15fc58 Cut metadata history over to shared model 2026-10-26 16:34:13 +00:00
76521f7cd8 Complete historical inventory and query services 2026-10-26 16:09:32 +00:00
cacc558288 Normalize original resolution history evidence 2026-10-26 15:49:15 +00:00
231ae66857 Normalize typed CLI history evidence 2026-10-26 15:36:42 +00:00
da74d4190d Normalize inheritance history evidence 2026-10-26 15:25:02 +00:00
11f7154874 Normalize projected collection history evidence 2026-10-26 15:09:12 +00:00
3828b117e8 Normalize baseline and source history evidence 2026-10-26 15:01:54 +00:00
2419e63717 Add shape-aware history selection core 2026-10-26 14:47:18 +00:00
a893c7716c Preserve explicitness across resolution shape changes 2026-10-26 14:36:15 +00:00
28ef9b6200 Use entry metadata for resolution presence 2026-10-26 14:30:25 +00:00
0dd16aa8a9 Plan the interface remediation sprint 2026-10-26 14:24:07 +00:00
f6d54a094a Clarify shared history migration and sprint acceptance 2026-10-26 14:00:18 +00:00
f9ec66e867 Adopt shared history ownership in the interface roadmap 2026-10-26 12:36:17 +00:00
be5cdeb502 Plan interface contract remediation 2026-10-26 02:40:21 +00:00
1f64673d0d Record API contract audit findings 2026-10-26 01:36:20 +00:00
01b08e76a3 Retire the completed consolidated audit 2026-10-26 01:25:35 +00:00
453da684d7 Write release notes for v0.10.2 2026-10-26 01:19:38 +00:00
fb6c254d38 Mark import simplification roadmap complete 2026-10-26 00:21:57 +00:00
a71413ce67 Document import and facade boundaries 2026-10-26 00:21:40 +00:00
d66338a1c5 Construct placement directly across the facade 2026-10-26 00:18:12 +00:00
643f43ce6a Snapshot import defaults at load preflight 2026-10-26 00:13:34 +00:00
c25cf39d19 Consolidate engine import defaults 2026-10-26 00:10:07 +00:00
7d18c93339 Extract patterned glob entry preparation 2026-10-26 00:08:23 +00:00
b1d89810ca Plan discovery and facade boundary refactoring 2026-10-26 00:03:02 +00:00
bb5540b62e Plan import discovery and facade simplification 2026-10-25 23:55:45 +00:00
80cf23e710 Mark the implementation roadmap complete 2026-10-25 23:26:29 +00:00
ac48557535 Document and verify the remediation sprint 2026-10-25 23:26:10 +00:00
b4af900c10 Index secret failure locations lazily 2026-10-25 23:21:29 +00:00
f132cd6468 Index inherited compound identity restoration 2026-10-25 23:17:59 +00:00
75e209eb3b Index tier history sources lazily 2026-10-25 23:13:00 +00:00
1dd94a3cc6 Share sequence history construction 2026-10-25 23:09:41 +00:00
d172a5e46a Apply sparse collection policy across overlays 2026-10-25 23:05:23 +00:00
3a1b6ff355 Borrow lower collection policies during strict composition 2026-10-25 22:59:03 +00:00
68383f34de Index collection policy retirement 2026-10-25 22:49:02 +00:00
549fca239d Separate collection overlay responsibilities 2026-10-25 22:42:21 +00:00
883244936c Share layered tier application 2026-10-25 22:37:46 +00:00
a8ae2e9d5e Clarify load context and inheritance handoffs 2026-10-25 22:34:52 +00:00
f18582106b Isolate standalone input completion 2026-10-25 22:32:52 +00:00
d8bf6abc2b Isolate source directive processing 2026-10-25 22:30:46 +00:00
4bf940c579 Use deferred acquisition consistently 2026-10-25 22:28:35 +00:00
4fd3bee986 Strengthen composition and import selection tests 2026-10-25 22:21:51 +00:00
6f6130fd12 Use production directive extraction in import tests 2026-10-25 22:19:38 +00:00
3122e2f32d Remove unread private transport members 2026-10-25 22:18:12 +00:00
e4a5ecb4be Remove disconnected facade clone scaffolding 2026-10-25 22:13:00 +00:00
826062aa17 Test public historical path enumeration 2026-10-25 22:09:55 +00:00
08f2a8f3e9 Cache approved dynamic descriptors per operation 2026-10-25 22:07:37 +00:00
12d350a292 Enforce the retained history node limit 2026-10-25 22:04:54 +00:00
4fcff5f453 Prepare inspection history before publication 2026-10-25 21:53:24 +00:00
247a12bc50 Remove unused combination evidence and helpers 2026-10-25 21:49:52 +00:00
119b42c810 Remove unused history document copies 2026-10-25 21:47:53 +00:00
0f9ecf12ed Share link dependency evaluation with host access 2026-10-25 21:45:33 +00:00
7bf2f0c687 Memoize completed native link dependencies 2026-10-25 21:43:26 +00:00
8aba43da38 Preserve directory traversal failure phases 2026-10-25 21:33:36 +00:00
877c497325 Close owned import roots during panic unwinding 2026-10-25 21:27:47 +00:00
08a8c25542 Close callback contexts when callbacks panic 2026-10-25 21:25:57 +00:00
ea27f17f5c Check projection budgets before container allocation 2026-10-25 21:25:00 +00:00
ae3408a096 Distinguish finite slice views from cycles 2026-10-25 21:22:01 +00:00
deb9aa5c69 Roll back failed dynamic descriptor builds 2026-10-25 21:20:41 +00:00
2736ddfbf2 Terminate pointer-only descriptor traversal 2026-10-25 21:18:35 +00:00
7889ebddfb Plan the audit remediation sprint 2026-10-25 20:52:11 +00:00
4a5316b7fc Describe the audit remediation target and dependencies 2026-10-25 20:25:18 +00:00
e1e94eeec7 Settle audit refactoring scope and defer follow-up work 2026-10-25 20:17:47 +00:00
176e9a5d90 Select localized audit efficiency improvements 2026-10-25 20:13:57 +00:00
23c1976ef1 Settle the initial audit remediation scope 2026-10-25 20:12:12 +00:00
aab79d9508 Consolidate repository audit findings for review 2026-10-25 20:07:46 +00:00
ecd5d5d460 Record dead code and obsolete functionality audit 2026-10-25 17:22:25 +00:00
b1c278b38b Record test coverage and effectiveness audit findings 2026-10-25 17:07:39 +00:00
eb5a82f1f3 Record security audit findings 2026-10-25 16:53:57 +00:00
b30c9f167d Record error handling audit findings 2026-10-25 16:44:08 +00:00
5925066d35 Record resource management audit findings 2026-10-25 16:37:05 +00:00
0cca633a67 Record concurrency and lifetime audit results 2026-10-25 16:25:10 +00:00
a80273433e Record maintainability and complexity audit findings 2026-10-25 16:18:07 +00:00
320d0c5f43 Record algorithmic efficiency audit findings 2026-10-25 16:05:49 +00:00
5098b56655 Record functional correctness audit findings 2026-10-25 15:52:43 +00:00
47944d22d1 Update ReleaseKit to v0.4.2 2026-10-25 15:37:32 +00:00
50199eb0af Retire completed consolidation roadmap documents 2026-10-25 15:09:34 +00:00
34cbc0d63b Document ConfigKit v0.10.1 2026-10-25 15:07:06 +00:00
5e12b2d360 Retire the completed clone traversal review 2026-10-25 14:58:56 +00:00
8dfaf44d1d Index import dependencies for local scope scheduling 2026-10-25 14:41:07 +00:00
64be8b4df5 Keep import selection evidence with its declaring source 2026-10-25 14:36:39 +00:00
e585decdb2 Reuse validated filesystem facts through acquisition 2026-10-25 14:33:17 +00:00
6440a500f7 Record import bookkeeping scalability findings 2026-10-25 14:29:40 +00:00
d898cb193d Record the remaining context consolidation gap 2026-10-25 14:00:29 +00:00
557e006a4a Complete consolidation roadmap validation 2026-10-25 13:51:36 +00:00
3fd83b6b2b Document consumer and internal loading flows 2026-10-25 13:49:44 +00:00
0fbce5d141 Separate schema field discovery and assembly 2026-10-25 13:46:51 +00:00
ac4ce242a2 Separate reflection projection handlers 2026-10-25 13:45:27 +00:00
fa6e876ab5 Extract inheritance accounting and finalization 2026-10-25 13:43:26 +00:00
c5275df1fc Return inheritance combination evidence together 2026-10-25 13:41:41 +00:00
f404168900 Extract inheritance input preparation 2026-10-25 13:32:01 +00:00
729c6fb5d5 Extract imported source admission from traversal 2026-10-25 13:28:43 +00:00
d314a269d5 Separate directory entry admission from traversal 2026-10-25 13:25:37 +00:00
2c3eb3308b Extract import declaration parsing helpers 2026-10-25 13:23:09 +00:00
6bc625b68f Split import acquisition and projection responsibilities 2026-10-25 13:21:29 +00:00
b915dd776a Consolidate selected overlay acquisition 2026-10-25 13:18:54 +00:00
9a64a65b6c Carry resolved filesystem facts through loads 2026-10-25 13:13:47 +00:00
6158e9db3b Separate filesystem context from access 2026-10-25 13:09:44 +00:00
0d149e51f0 Wire load lifecycle through input acquisition 2026-10-25 13:07:16 +00:00
bd51b9c3f2 Consolidate import request ownership 2026-10-25 13:02:46 +00:00
d05426d8a2 Introduce non-generic input acquisition requests 2026-10-25 12:55:04 +00:00
3f0efcb4ff Normalize ordinary and grouped load preflight 2026-10-25 12:52:50 +00:00
19f5c2a2d5 Remove unused reverse source conversions 2026-10-25 12:50:11 +00:00
455a4a355d Map ConfigKit consolidation callers and coverage 2026-10-25 12:37:33 +00:00
8ab1568352 Plan the ConfigKit consolidation sprint 2026-10-25 12:14:01 +00:00
8a99e61070 Defer clone traversal review to future work 2026-10-25 11:54:46 +00:00
de7513d488 Settle retaining the public loading APIs 2026-10-24 23:07:09 +00:00
26771dc03e Consolidate refactoring roadmaps and record open questions 2026-10-24 22:04:26 +00:00
fbaaaf18cd Draft the ConfigKit consolidation roadmap toward 1.0 2026-10-24 21:51:29 +00:00
c9574ef3c1 Record complexity audit findings 2026-10-24 19:54:13 +00:00
5959537d1a Prepare the ConfigKit v0.10.0 release 2026-10-24 19:45:33 +00:00
0369111d30 Resolve Windows rooted search paths with Go 2026-10-24 19:24:30 +00:00
2fedb0307f Use Go rules for Windows filename components 2026-10-24 19:20:54 +00:00
087050fa25 Isolate user configuration discovery tests by platform 2026-10-24 19:18:13 +00:00
1ac1f8bc9e Preserve failing search candidate diagnostics 2026-10-24 19:16:50 +00:00
4dc3e70877 Validate search filesystem context after selection 2026-10-24 19:13:35 +00:00
f64497cb13 Record discovery algorithm audit findings 2026-10-24 19:03:04 +00:00
68cf5c489b Record configuration discovery review findings 2026-10-24 17:52:20 +00:00
e50fee2ca5 Complete the configuration discovery implementation plan 2026-10-24 16:26:09 +00:00
eec214944c Align discovery ownership and contributor documentation 2026-10-24 16:24:57 +00:00
4f2c113237 Extend discovery coverage across consumer workflows 2026-10-24 16:20:53 +00:00
2d93a5c703 Expose configuration search through the public facade 2026-10-24 16:16:07 +00:00
2f6c1eca00 Integrate configuration searches into engine loading 2026-10-24 16:11:03 +00:00
8642337966 Load ordinary overlays without a main source 2026-10-24 15:59:39 +00:00
53ce6c63d8 Allow absent inputs to use import contexts 2026-10-24 15:55:10 +00:00
2f0900535d Select configuration files through host rules 2026-10-24 15:46:36 +00:00
4748313105 Expand ordered configuration search candidates 2026-10-24 15:43:10 +00:00
e2634c8815 Define immutable configuration searches 2026-10-24 15:39:59 +00:00
424c89780e Plan configuration discovery implementation 2026-10-24 15:04:02 +00:00
92309ddcc3 Update the discovery roadmap baseline 2026-10-24 14:54:56 +00:00
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
325 changed files with 19461 additions and 10009 deletions

3
.gitignore vendored
View File

@@ -28,6 +28,9 @@ go.work.sum
# Local codebase-memory tooling artifacts
/.codebase-memory/
# Local release credentials and tooling
/.releasekit/
# ---> Linux
*~

View File

@@ -18,8 +18,3 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms.
Policy documents adapted from Standard Repository Policies, the root AGENTS.md
orientation, and the Policy Reading Guide in docs/development.md remain licensed
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
upstream credit, and license links.

132
README.md
View File

@@ -1,51 +1,18 @@
# ConfigKit
An opinionated Go library for applications that use YAML configuration files.
ConfigKit loads YAML into your application's Go configuration type, combines the
inputs you choose, and lets you inspect where the final values came from. You
supply the types, defaults, and application rules; ConfigKit handles loading,
combination, strict decoding, and inspection.
Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details.
[platform support policy](docs/policy/release.md#platform-support).
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 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,
then report domain errors and warnings through read-only semantic validation. Secret
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 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
`Diff` API compares frozen effective values from two successful same-type loads;
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).
## Load a configuration file
```go
import "go.maximumdirect.net/configkit"
type Config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
@@ -60,48 +27,57 @@ func ReadConfig(path string) (Config, error) {
}
```
Existing consumers should follow the
[module-path migration guidance](docs/consumers/README.md#vanity-module-path-migration).
## Add the features you need
Start with one file. Optional features use the same loading and inspection model:
- [Choose inputs](docs/consumers/README.md#choosing-a-loading-entry-point): read
files, an application-owned `fs.FS`, bytes, a reader, or a Go mapping. Use
explicit search descriptions when the application should look in several places.
- [Supply lower-priority settings](docs/consumers/README.md#native-filesystem-and-layered-input):
use `LoadLayered` for bundled defaults that application configuration can override.
- [Combine configuration](docs/config.md#explicit-imports): add declared imports,
select one overlay, or pass values explicitly set by your CLI parser.
- [Reuse settings](docs/config.md#sibling-inheritance): let one named entry inherit
from a sibling. [Identified lists](docs/config.md#strict-document-composition)
also support record inheritance and replace, append, or upsert operations.
- [Apply application rules](docs/config.md#recorded-application-resolution): derive
or normalize values in a resolver, then check the final configuration in a validator.
- [Handle credentials](docs/config.md#typed-secret-references): use environment-name
references and choose which configuration fields to hide in inspection output.
- [Inspect a load](docs/config.md#layered-metadata): examine sources, presence,
history, safe YAML output, or [changes between two loads](docs/config.md#effective-configuration-diffs).
The [consumer guide](docs/consumers/README.md) walks through these tasks and links
to maintained examples. The [configuration reference](docs/config.md) defines the
exact YAML, combination, and lifecycle rules.
## Design philosophy
ConfigKit favors one coherent, predictable configuration model over flexibility
for reproducing each consumer's conventions. Its goal is to let applications
load once, inspect where final values came from, and distinguish explicit values
from omissions with minimal configuration bookkeeping. Applications retain their
domain rules and execution responsibilities.
ConfigKit favors consistent, predictable rules across applications. A load returns
the final configuration and metadata that distinguish supplied values such as
`false` and `0` from omissions. Applications keep their domain rules and runtime
initialization.
Loading is synchronous and complete on return. Applications normally load once
near startup and then use the final configuration and metadata; ConfigKit stays
inactive until explicitly called again. Applications may initiate additional
loads as needed. See the [loading lifecycle](docs/config.md#completion-and-resource-lifetime)
for completion and ownership details.
Within one layer, independent contributions combine strictly: conflicting
values are errors. Across layers, higher-priority settings can replace
lower-priority settings, while mappings combine by key. Identified lists have
their declared combination rules. See the [worked combination
examples](docs/config.md#strict-document-composition) for the details and
exceptions.
Within a configuration tier, independent contributions compose strictly: shared
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 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.
Loading finishes before the call returns. ConfigKit then stays inactive until you
call it again; inspection uses the saved result. See
[completion and resource lifetime](docs/config.md#completion-and-resource-lifetime).
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
these priorities and how genuine new needs can justify extending the shared
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,
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.
The [architecture goals](docs/policy/architecture.md#success-criteria) and
[ADRs](docs/adr) explain design decisions, including shared import and inheritance
ordering. [Future work](docs/roadmap/future.md) lists possible extensions.
Contributors can start with the [development guide](docs/development.md);
release maintainers use the [release procedure](docs/release.md).
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

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

View File

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

22
diff.go
View File

@@ -1,15 +1,14 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
import "go.maximumdirect.net/configkit/internal/engine"
// Difference describes one canonical changed region between two successful
// loads. Left is the earlier value and Right is the later value. Each snapshot
// is detached; Present distinguishes absence from an explicit null, and
// Redacted reports whether combined sensitivity affected that side. Mappings
// are compared recursively, while sequences are reported as atomic values.
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
// a value concealed by combined sensitivity changed, without revealing which
// concealed member changed.
// Difference describes one changed value or subtree between two successful loads.
// Left is the earlier value and Right is the later value. Snapshots are copies;
// Present distinguishes absence from explicit null. Redacted reports hiding
// based on sensitivity from either load. Mappings compare by key; a changed
// sequence is reported as a whole value. Records are ordered by canonical JSON
// pointer. HiddenChanged reports an actual hidden change, without identifying
// the hidden member. Redaction alone does not set it.
type Difference struct {
Path Path
Left ValueSnapshot
@@ -17,8 +16,9 @@ type Difference struct {
HiddenChanged bool
}
// Diff compares the frozen effective values of two successful loads of the
// same configuration type. It does not reread sources, environment variables,
// Diff compares the saved final values of two successful loads of the same
// configuration type, independently of later changes to Result.Config.
// It does not reread sources, environment variables,
// or rerun defaults or resolvers. Equal values produce no records even when
// their sources, defaults, or resolution histories differ. Credential contents
// are not compared: typed secret references compare by reference name only.

90
discovery.go Normal file
View File

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

22
doc.go
View File

@@ -1,9 +1,17 @@
// Package configkit provides deterministic, application-owned configuration
// loading for Go programs.
// Package configkit loads YAML into an application's Go configuration type and
// lets the application inspect where the final values came from.
//
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
// typed defaults, imports, overlays, CLI overrides, schema-version checks, detached
// metadata, recorded application resolution, typed secret references, safe effective
// rendering, read-only semantic validation, and structured load errors. The
// configuration contract is documented in docs/config.md.
// Start with Load and a File, Bytes, Reader, Mapping, or Filesystem.File source.
// Use NoSource for deliberately absent input or SearchConfig for an explicitly
// chosen search. Applications supply types, defaults, version rules, and callbacks
// for deriving and validating values. LoadLayered adds lower-priority input groups.
//
// A successful load returns the final configuration and saved inspection state.
// Metadata tracks supplied values, including false and zero; explanations retain
// safe earlier values. WriteEffective and Diff use the saved state even if the
// application later changes Result.Config. Secret references resolve credentials
// during loading, while inspection follows the configured hiding rules.
//
// The consumer guide is in docs/consumers/README.md. The exact YAML, combination,
// lifecycle, and ownership rules are documented in docs/config.md.
package configkit

View File

@@ -10,6 +10,9 @@ 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`.
[ADR 0020](0020-document-root-controls.md) amends the scope of the control-name
reservation. The other placement decisions in this record remain in force.
## Date
2026-10-14

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

@@ -0,0 +1,61 @@
# ADR 0020: Scope Configuration Controls to Document Roots
## Status
Accepted.
## Date
2026-11-01
## Context
ConfigKit interprets configuration controls during source acquisition, before
placing and composing source payloads. Reserving the control name throughout
application data restricts paths unrelated to source-document controls. The
source-local position of a control and the effective application path of a
value are different coordinates, even when their path text is identical.
## Decision
Use `config` as the single control mapping name at the root of each acquired
source document. Interpret each source's controls before placing its payload,
using the existing context-specific grammar and import opt-in. A source retains
its own document root regardless of its eventual destination, including when
mounted below another value or as a sequence record.
Below a source root, `config` is ordinary application data, including in
sequence records and selected overlays. Control-looking children in such data
do not activate controls. Application-root `config` data produced by placement,
defaults, CLI assignment, or resolution is also ordinary data. Placement and
later lifecycle operations do not create another source-document parsing pass.
The former spellings `configkit` and `_configkit` are ordinary application keys
at any depth, subject to normal schema and structural rules; they are not
control aliases. Source-version checks protect actual source-local controls,
not an application path merely because its spelling matches. Existing `extends`
inheritance semantics and restrictions remain in force.
## Alternatives Considered
- **Reserve the control name throughout application data.** This conflates
source-document syntax with application paths and prevents ordinary fields
unrelated to control interpretation.
- **Recognize controls again after placement or later operations.** This makes
application data acquire control semantics according to its destination and
introduces a second interpretation pass.
- **Support multiple or configurable control spellings.** This makes control
interpretation ambiguous and expands the configuration contract.
## Consequences
Control evidence uses source-local coordinates while assembled values use
application coordinates. Existing source identities and contexts distinguish
these uses without a new inspection-record format. Consumers must migrate
genuine old control blocks and account for source-root application fields
named `config`; ordinary nested and assembled root fields remain available.
The completed feature roadmap and implementation plan are preserved in Git
history at `c63f4ff` under `docs/roadmap/`, for example
`git show c63f4ff:docs/roadmap/implementation.md`.
The [configuration contract](../config.md) owns implemented behavior.

View File

@@ -0,0 +1,65 @@
# ADR 0021: Match Registered Records During Resolution Inspection
## Status
Accepted.
## Date
2026-11-02
## Context
Application resolution can change fields within sequences. Treating every
sequence change as atomic prevents a field record from covering an individual
edit. Registered collection identities also provide a way to relate a final
record to its entry value when positions change, so inspection can retain the
correct source evidence without confusing it with a prior index occupant.
## Decision
Compare equal-length ordinary lists recursively by position during resolution
auditing. A length change requires coverage of the list or an ancestor. For a
collection with an applicable application identity registration, match entry and
final records only when both endpoints have valid, unique identities. A change
in identity order or membership requires list or ancestor coverage, while
surviving records retain their matched entry evidence. When matching is not
available, audit a changed list as a whole without introducing an early identity
failure. Final registration validation remains at its existing boundary.
Match only the resolution-entry and final snapshots, within the same registered
destination. Identity changes are removals and additions, not inferred renames.
Records and helpers address final positions; entry queries remain fixed to entry
coordinates, and helpers do not follow later moves automatically. A surviving
record takes precedence at a reused position; collection inspection retains
removed records' evidence, and absent historical positions remain inspectable.
New records do not inherit a previous occupant's history or explicitness.
Preserve recorded operations, source coordinates, sensitivity, and safe detached
inspection. Resolution comparison is separate from public effective diff, which
continues to present sequence changes atomically.
This extends resolution inspection under [ADR 0003](0003-preserve-presence-and-resolution-history.md)
while preserving the diff decision in [ADR 0010](0010-effective-configuration-diff.md),
source combination and identity rules in [ADR 0017](0017-collection-sequence-policy.md),
and lifecycle ordering in [ADR 0018](0018-cli-after-structural-resolution.md).
## Alternatives Considered
- **Keep all sequence changes atomic during resolution.** This forces whole-list
records for independent field edits and does not identify a moved record's
earlier evidence.
- **Infer identities for every list.** Similar values or conventional field
names do not establish an application identity and could attach unrelated
history to a final record.
- **Reconstruct every intermediate callback mutation.** This requires a new
mutation history and implies an ordering that endpoint snapshots cannot prove.
## Consequences
Resolution auditing and inspection must share entry-to-final correspondence,
including explicit unmatched records, while preserving historical coordinates
and conservative sensitivity masking. Source assembly and public path types do
not change. The completed implementation plan is preserved in Git history at
`7e53863:docs/roadmap/implementation.md`; the
[configuration contract](../config.md) owns implemented behavior.

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +1,142 @@
# Consumer Adoption Guide
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from
applications. Consumers still own their configuration structs, defaults, semantic
validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization.
ConfigKit loads configuration into your application's Go types. You provide
those types, defaults, and application rules. ConfigKit reads and combines the
selected inputs, checks their types, and returns values with source information.
Your application still owns CLI parsing, resource reads, remote acquisition, and
runtime initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library:
Import `"go.maximumdirect.net/configkit"`; the package name is `configkit`.
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| 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 |
Document controls now use root `config` in every source. Rename genuine old
`configkit` or `_configkit` control blocks and update stored declaration paths
from `/configkit/...` to `/config/...`. Old blocks become ordinary data and a
permissive schema can accept them without executing imports. Move, rename, or
separately mount application fields previously authored as source-root `config`.
Nested and assembled root `config` values remain ordinary application data; the
[mounted-root example](../../examples/consumers/config_controls_test.go) shows
the boundary. See the [configuration contract](../config.md#declaration-and-selection-rules)
for the complete rules.
Start with one file, then choose the tasks your application needs:
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
| Task | Start here |
| --- | --- |
| Load one file | [Happy path](#happy-path) and the [README example](../../README.md#load-a-configuration-file) |
| Supply fixed defaults and check application rules | [Defaults and application rules](#defaults-and-application-rules) |
| Add bundled defaults beneath application settings | [Native filesystem and layered input](#native-filesystem-and-layered-input) |
| Select files or an overlay | [Discovery](#explicit-main-file-discovery), [import defaults](#import-defaults-and-selection-inspection), and the [overlay reference](../config.md#selected-overlays) |
| Reuse settings or combine record lists | [Inheritance](#named-sibling-inheritance) and [identified sequences](#identified-prompt-sequences) |
| Handle credentials | [Typed-secret reference](../config.md#typed-secret-references) and the [startup example](../../examples/consumers/consumers_test.go) |
| Inspect sources, earlier values, or changes | [Inspect a completed load](#inspect-a-completed-load) and [diffs](#effective-configuration-diffs) |
| Upgrade an existing consumer | [Migration guidance](#intentional-migration-changes) |
The Notarius-shaped fixtures deliberately load both `version: 4` and
`version: "4"` into the same string-backed application field. Its version checker
still owns the supported identifier; fractional numeric input and unsupported
integer identifiers remain negative examples.
## Choosing a loading entry point
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
[`SearchConfig`](../../discovery.go) is also a source; pass it to `Load` or use
it as the main input to [`LoadLayered`](../../layered.go).
- Use `LoadLayered` when several inputs should supply lower-priority defaults
beneath the main source. Each underlay group combines its inputs strictly.
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).
## Happy path
An application defines ordinary types and passes one explicit source. Optional
features appear only when the application needs them:
Define the Go configuration type, choose one source, and call `Load`. Handle a
load error before starting the application with `result.Config`. The
[README example](../../README.md#load-a-configuration-file) shows this complete
minimal loading function. An explicitly missing file is an error; use `NoSource`
when you deliberately want defaults without a main document.
### Defaults and application rules
Using the README's `Config` type, an application can add reusable rules:
```go
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
Definition: rules,
Imports: useDeclaredImports,
Overlay: selectedConfigOverlay,
Overrides: explicitlySetFlags,
})
if err != nil {
return err
definition := configkit.Definition[Config]{
Defaults: func() Config { return Config{Enabled: true, Workers: 2} },
Validate: func(value *Config, validation *configkit.Validation) {
if value.Workers < 0 {
validation.Error(configkit.Keys("workers"), "must be nonnegative")
}
},
}
return start(result.Config)
```
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into
both without redefining their syntax. ConfigKit overlays are configuration overlays;
they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts.
Pass this definition in `Options[Config]{Definition: definition}` when calling
`Load`. Supplied `enabled: false` or `workers: 0` replaces those defaults.
Use `Resolve` when a value must be derived or normalized before validation, and
record that change with the provided helpers. The
[callback reference](../config.md#recorded-application-resolution) compares the
helpers; [executable examples](../../example_test.go) show full definitions.
### Inspect a completed load
Keep the result when you need to explain configuration. Use
`result.Metadata().Lookup(configkit.Keys("workers"))` to inspect whether input
supplied the value and which source or application step supplied it. Follow an
origin's source ID with `result.Source(id)` when you need its source record.
Use `Explain` for earlier values, `WriteExplanation` for a readable explanation,
and `WriteEffective` for safe YAML describing the final saved state. These
operations describe the completed load even if your application later changes
`result.Config`. The [inspection reference](../config.md#layered-metadata)
explains returned values, copies, and hidden information.
Add imports, a selected overlay, or explicitly set CLI values through options
only when needed. An overlay changes configuration; choosing an LLM, report, or
execution profile remains application logic.
## 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 [discovery
API](../../discovery.go) and [configuration discovery
contract](../config.md#configuration-discovery) explain the order of attempted
files, what happens when no file is found, and why a selected-file failure
stops the search. [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
application default, then the library default. `Required`, `FollowSymlinks`,
`IncludeHidden`, 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.
The Go fields `FollowSymlinks` and `IncludeHidden` correspond to the unchanged
YAML keys `follow` and `hidden`.
`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;
declaring source's `EmptyImportSelections`. `Result.Source(id)` and
`LoadError.Source(id)` retrieve one retained record directly when its load-local
ID is known. These records identify the source-local declaration path, its
authored location and selector;
they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics.
@@ -78,7 +146,7 @@ owns selector applicability and exact absence semantics.
[`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
`SourceInfo.VersionEvidence` 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,
@@ -96,13 +164,14 @@ priority values remain present. Its resolver records a derived endpoint URL afte
overlay application, and its nested typed secret reference is classified before
the resulting records are presented.
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
use temporary files and disposable secret values. Presenters should use
`ValueSnapshot.Present` for absence, record membership for change, and
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
or add separately rendered full contexts that lack combined sensitivity. The
[configuration contract](../config.md#effective-configuration-diffs) owns shared
equality, granularity, and disclosure semantics.
The maintained [behavior tests and executable
examples](../../examples/consumers/diff_test.go) use temporary files and
disposable secret values. Presenters should use `ValueSnapshot.Present` for
absence, record membership for change, and `HiddenChanged` for a withheld
change indicator. Do not parse the redaction marker or add separately rendered
full contexts that lack combined sensitivity. The [configuration
contract](../config.md#effective-configuration-diffs) owns shared equality,
granularity, and disclosure semantics.
## Named sibling inheritance
@@ -225,14 +294,13 @@ conversion API.
## Source-aware application path policy
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
path-bearing example. Its application resolver uses `Resolution.EntryInfo` and
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
supplied invocation directory, retained defaults use the canonical main source,
and a file-authored scalar uses its one declaring source. Absolute paths remain
unchanged. Its executable test covers the matching effective YAML and explanation
history, source-file ownership, and a symlink opened through an alias; canonical
target-file directories, rather than the alias directory or process working
directory, are the selected bases.
unchanged. Its executable test checks the resulting YAML and explanation
history. When the configuration filename is a symlink, this example uses the
actual target file's directory as the base for file-authored paths.
The selected example policy leaves a relative value unchanged and emits a warning
when a CLI base is absent or file provenance is missing or composite. That is an
@@ -240,9 +308,77 @@ application choice, not a ConfigKit fallback: consumers can instead reject it or
choose another explicit base. The example intentionally performs neither target
existence checks nor confinement checks.
### Normalizing paths inside a list
Suppose `/srv/catalog/resources.yml` supplies this configuration, and the
application interprets file-authored relative paths against their declaring
file's directory:
```yaml
resources:
- name: reports
path: ./reports
- name: invoices
path: ./invoices
```
Apply the same source-aware policy as `LoadNotariusPaths` to each `path` field
while the list remains in its entry order:
1. Build the field's semantic path, such as
`configkit.Keys("resources").Index(0).Key("path")`.
2. Query `Resolution.EntryInfo` at that path and use `Resolution.Source` to
identify its declaring file. Choose the base under the application's policy;
defaults, CLI values, and missing or composite provenance need their own
handling, as in the maintained example above.
3. Resolve the relative value against that base and use `Normalize` to assign
and record the individual field. For a mutation already performed by the
application, use `RecordNormalization` instead.
4. Continue without reordering the list. Later resource access uses the resolved
value rather than trying to recover a base directory from its effective origin.
For these file-authored values, the resulting configuration is:
```yaml
resources:
- name: reports
path: /srv/catalog/reports
- name: invoices
path: /srv/catalog/invoices
```
Each changed `path` has a resolution origin, while its history retains the
earlier relative value and source evidence. Unchanged fields such as `name`
retain their source origins. Field-level recording avoids attributing the whole
list to the path-normalization operation; it does not preserve a file origin on
the normalized fields themselves.
If the application also restructures the list during resolution, a whole-list
record covers the structural change. For a collection registered with `name`
as its identity, history follows each surviving record through a reorder, but
descendants can acquire synthetic resolution origins without source locations.
Historical source evidence and effective origins answer different questions;
identity-aware history does not automatically select a resource base for a
derived value. Resolve source-relative values before moving records, and account
for final positions when recording helper targets. `EntryInfo` continues to use
entry positions even after a move.
See [resolution recording](../config.md#resolution-recording-and-failure-rules)
for helper and coverage behavior, and
[origins and historical paths](../config.md#presence-origins-and-historical-paths)
for the distinction between resolution origins and retained source evidence.
## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention:
When upgrading, start with the changes relevant to your dependency and inputs:
1. Update the module requirement and Go imports together as described below.
2. Replace old public names using the old/new table.
3. Update any layer or diagnostic-code strings stored or compared by your code.
4. Review YAML and startup changes that apply to your application, then rebuild
and run its configuration tests.
The maintained examples illustrate these configuration changes:
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
@@ -262,10 +398,70 @@ The examples preserve domain meaning while adopting one configuration convention
parsers translate only explicitly set configuration flags into `Override` records.
Separate invocation state remains outside configuration.
- Credential fields migrate to environment-name `Secret` references. Applications
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap;
explicitly call `LoadSecretsIntoEnvironment` before `Load` when they choose
file bootstrap;
blocked empty variables remain blocked, and ordinary `Load` never changes the
process environment.
### Vanity module path migration
ConfigKit's module identifier changed from
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit` before
v1.0.0. Update the dependency requirement in your application's `go.mod` and its
Go imports together, selecting a revision or release that declares the new module
path. The package name remains `configkit`; the configuration model and public
symbol names are unchanged by this identity migration.
Go treats the old and new import paths as different packages, with different
named types. For example, if another library returns a ConfigKit `Path` or
`Secret` using the old import, your application cannot use it as the new
package's type just by changing a module requirement. Coordinate migration
with dependencies that pass ConfigKit values to or from your application. The
maintained examples use the new path, but other repositories must manage their
own migrations.
Historical tags keep their original module declarations and contents. Older
releases such as v0.10.2 still use the old path and cannot be installed under the
vanity identifier simply because discovery can find their repository. A matching
migrated revision is needed; no old-path compatibility module is supplied here.
### Pre-v1 public API cleanup
The pre-v1 API cleanup replaces these names outright; it does not retain
compatibility aliases:
| Previous API | Current API |
| --- | --- |
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
| `Definition.Secret` | `Definition.Sensitive` |
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
| `SourceVersionCheck` | `SourceVersionEvidence` |
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
| `ImportSelection` | `EmptyImportSelection` |
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
| `Paths(paths...)` | `SearchPaths(paths...)` |
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
| `LayerBase` | `LayerMain` |
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
| `CodeResolveInvalidKind` / `CodeOptionsUnavailable` | Removed; no replacement |
| `ImportSelection.Outcome`, `ImportSelectionOutcome`, `ImportSelectionNone` | Removed; empty-selection records no longer carry an outcome |
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
| Enumerating `Result.Sources()` / `LoadError.Sources()` to find one ID | `Result.Source(id)` / `LoadError.Source(id)` |
| Provenance string `"base"` | `"main"` |
| History diagnostic string `"explain.limit_exceeded"` | `"history.limit_exceeded"` |
The YAML import keys `follow` and `hidden`, the `configkit:"secret"` tag, and
typed `Secret` references are unchanged. If your application compares or
stores layer names or diagnostic codes as strings, update those strings
alongside Go references. This repository guidance does not claim that
downstream applications have migrated and does not select a release version
for this breaking pre-v1 change. `ChangeKind` and its inspection constants
remain; use `Default` and `Normalize` when the helper should both assign and
record a value.
### Selected-overlay migration
To adopt the current selected-overlay API, rename `Options.Profile`,
@@ -276,10 +472,40 @@ The [configuration contract](../config.md#selected-overlays) is the canonical
behavioral reference. Application-owned profile concepts, such as `model_profile`,
are unchanged, and any CLI flag rename remains the application's decision.
These examples are migration evidence, not completed changes to Narratio, Notarius,
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
wiring, runtime catalogs, and operational documentation remains separate downstream
work.
### Background on the examples
These examples demonstrate migration shapes. They do not claim that Narratio,
Notarius, WeatherReporter, or Seriatim has been migrated. Updating those
repositories, their deployed YAML, CLI wiring, runtime catalogs, and
operational documentation remains separate downstream work.
## Example background
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Artifact defaults and normalization (Narratio-like) | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Versions and dynamic options (Notarius-like) | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| Module validation and credential startup (WeatherReporter-like) | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Independent ordered rule files (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,
credentials, network calls, or dependency on a sibling checkout.
The Notarius-shaped fixtures deliberately load both `version: 4` and
`version: "4"` into the same string-backed application field. Its version checker
still owns the supported identifier; fractional numeric input and unsupported
integer identifiers remain negative examples.
## Maintained API examples
@@ -291,7 +517,7 @@ focused behavioral owner:
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| `LoadSecretsIntoEnvironment`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
| 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` |

View File

@@ -63,6 +63,9 @@ Accepted decisions:
- [Separate import selection controls](adr/0016-independent-import-controls.md)
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -90,6 +93,19 @@ and current facade ownership belongs to the [component overview](internal/overvi
## Baseline validation
The completed field-level sequence resolution roadmap, implementation plan, and
resolved audit findings are preserved in Git history at `7e53863` under
`docs/roadmap/`. For example:
```sh
git show 7e53863:docs/roadmap/implementation.md
git show 7e53863:docs/roadmap/remediation.md
```
The [configuration contract](config.md#recorded-application-resolution) owns
current resolution behavior, and [ADR 0021](adr/0021-sequence-resolution-correspondence.md)
records the correspondence decision.
The completed filesystem-source and underlay roadmap, original feature request,
implementation plan, and resolved remediation record are preserved in Git history
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
@@ -167,7 +183,63 @@ 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 completed audit-remediation, internal-boundary simplification, shared-history,
and public API cleanup roadmaps, reviews, and resolved findings are preserved at
`68400ff` under `docs/roadmap/`. For example:
```sh
git show 68400ff:docs/roadmap/api.md
git show 68400ff:docs/roadmap/remediation.md
```
Their earlier completed implementation plans are preserved at
`bb5540b:docs/roadmap/implementation.md` (audit remediation),
`f6d54a0:docs/roadmap/implementation.md` (internal-boundary simplification), and
`07e4ae5:docs/roadmap/implementation.md` (shared history). The plan at
`68400ff:docs/roadmap/implementation.md` records the public API cleanup.
Current behavior and migration information belong to the
[configuration contract](config.md), public declaration comments, and
[consumer guide](consumers/README.md#pre-v1-public-api-cleanup).
The consolidated audit, including
findings, evidence, and settled scope decisions, is preserved at
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
```sh
git show 453da68:docs/roadmap/audit.md
```
Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline
@@ -192,15 +264,19 @@ dependency downloads. Acquire dependencies and any selected toolchain beforehand
Format reported Go files with `gofmt -w <files>`.
The default suite includes executable examples, maintained consumer fixtures,
local documentation-link checks, and the dependency import-boundary check.
and the dependency/public boundary checks.
It must stay deterministic and runnable without network access or credentials.
Still inspect new/untracked content and review paths, examples, ownership, and
current-versus-planned claims; automated checks do not replace content review.
Use focused checks while iterating, with validation proportionate to the change
under the testing policy. Documentation-only work normally needs the local-link
check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes.
under the testing policy. Documentation-only work normally needs review of
affected links and whitespace; behavior-bearing examples and commands also
need execution. Automated Markdown link checking runs against the committed
candidate through ReleaseKit, not during ordinary Go tests or the everyday
checker. See the [release procedure](release.md#4-publish-the-committed-candidate)
for the selected checks and optional diagnostic command. Use
`go test -race ./...` for ownership or concurrency changes.
The [release procedure](release.md#4-publish-the-committed-candidate) performs
its selected validation through one `publish` invocation. The everyday checker
remains a development gate and is not a separate release prerequisite. CI never
@@ -215,28 +291,29 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command |
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| 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` |
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery\|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset\|SequenceCatalog'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import\|ScheduleDependencies\|Inheritance'`, `go test . -run 'ImportDefaults\|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination\|Import\|Glob\|Mounted'`, `go test . -run 'Destination\|Import\|SourceContext\|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Resolution sequence matching and inspection | [Correspondence](../internal/engine/resolution_correspondence_test.go), [auditing and history](../internal/engine/resolution_compare_test.go), [public integration](../facade_resolution_sequences_test.go) | `go test ./internal/engine -run 'Resolution\|History\|Explain\|Sensitivity'` and `go test . -run 'Resolution\|SequenceIdentity\|Diff'` |
| 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$'` |
| 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, 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'` |
| Dependency and public API isolation | [Import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
## Fuzz validation

View File

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

View File

@@ -7,33 +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, per-input mapping
and sequence-item placement, YAML collection declarations and operations for
identified sequences, and application identity registrations for strict
- `internal/engine` owns host and native-filesystem source acquisition and
explicitly selected configuration discovery. A search resolves once during
shared acquisition; the selected concrete file or absent-input state then
follows the existing composition, metadata, and resource-context lifecycle.
It also owns grouped strict composition, recursive precedence assembly,
per-input mapping and sequence-item placement, YAML collection declarations
and operations for identified sequences, and application identity registrations for strict
composition, inheritance, and final validation. It applies the selected
collection operation at precedence boundaries and owns dependency-ready,
destination-local inheritance before typed decoding. CLI assignment follows
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; load-wide defaults and source-local empty-selection evidence cross the
root facade without changing acquisition order. Import and inheritance
resolution share dependency-ready scheduling within their composition scopes.
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.
@@ -42,7 +50,7 @@ Implemented component owners:
- Engine mechanism tests own source, composition, inheritance, decoding,
callback, provenance,
source-context, secret, history, rendering, and bootstrap behavior. Root tests
own public API compatibility, facade integration, documentation links, and
own public API compatibility, facade integration, and
dependency/public boundary checks. Consumer examples remain under
`examples/consumers`.
@@ -53,7 +61,9 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics, including [input destinations](../config.md#input-destinations),
semantics, including
[configuration discovery](../config.md#configuration-discovery),
[input destinations](../config.md#input-destinations),
[shared source-version checks](../config.md#shared-source-version-checks), and
[effective configuration diffs](../config.md#effective-configuration-diffs).
The [strict composition contract](../config.md#strict-document-composition)
@@ -78,5 +88,105 @@ records the shared dependency-ready ordering decision; [ADR
0016](../adr/0016-independent-import-controls.md) records the independent
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
operations and CLI ordering. The [import internals](imports.md) document candidate and
namespace identities, discovery and scheduling, and the bounded algorithms.
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records explicit main-file discovery. The [import internals](imports.md)
document candidate and namespace identities, discovery and scheduling, and the
bounded algorithms.
## Implementation flow
The root facade converts caller-owned values into engine-owned requests. 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. Source-root controls are extracted from
each acquired tree before placement; assembled application data is not parsed
again for controls. Grouped requests retain acquired underlay
scopes until their tier is composed in
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
share one assembly handoff that applies collection declarations and projects
only that tier's source/version evidence; the caller keeps main-version
protection and its projection explicit. Live
filesystem access handles are confined to acquisition and closed there. Resolved
filesystem facts used to authorize access are separate from passively retained
resource contexts. Non-import validation retains those facts on `assembledInput`
for passive-context construction and ordinary-overlay handling: see
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
[`source_context.go`](../../internal/engine/source_context.go).
Import responsibilities are split across candidate selection and path
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
source reading and traversal admission in
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
and projection of acquired evidence into final source coordinates in
[`import_projection.go`](../../internal/engine/import_projection.go). The
[import internals](imports.md) page describes the boundaries and identities.
After acquisition handles are closed, grouped composition and inheritance run on
the retained ordered documents. `inheritance.go` coordinates traversal and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
dependency-ready templates;
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
prepares child and parent inputs,
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
returns the combined value with its origin, policy, sequence, and diagnostic
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
accounts for and finalizes the result. Projection and schema work have separate
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
descriptor caching and type policy, and
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
those descriptors for typed decoding.
History and inspection use one private evidence owner. The
[`history_model.go`](../../internal/engine/history_model.go) builder receives
baseline, source-composition, inheritance, typed-checkpoint, and CLI evidence
in [`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go),
before application resolution begins. The callback retains its separate fixed
entry view; it does not read a builder that continues to change. After successful
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
adds the original resolution records and their net replay groups. At the existing
resolution boundary, [`resolution_correspondence.go`](../../internal/engine/resolution_correspondence.go)
matches eligible registered records and the shared history retains their paired
coordinates for selection and safe replay. At the existing
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
freezes that builder and counts the retained document roots before secret lookup
and validation.
The frozen model is shared by `Metadata` and `Inspection`. Its shape-aware
selection in [`history_select.go`](../../internal/engine/history_select.go)
drives metadata history, explanation, and historical-path membership; the model
also supplies historical diagnostic locations and sensitivity/dependency
evidence. `Inspection` replays selected payloads to produce detached, safely
rendered values, while metadata queries return detached attribution records.
History and explanation behavior is covered by focused `history_*_test.go`
tests alongside metadata, secret, inheritance, and facade integration tests.
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
covered by `imports_test.go`, `load_request_test.go`, and focused source,
discovery, and destination tests; inheritance by the inheritance test files;
reflection behavior by `schema_test.go`, `projection_test.go`, and
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
copying, using the shared descriptors and per-operation traversal helpers.
Each schema operation builds its private reflection descriptor graph with
operation-local caching; there is no global mutable descriptor cache. Decoding,
projection, and cloning reuse these descriptors so field bindings agree. The
public reference describes the resulting type, output, and ownership behavior;
this descriptor storage is an implementation detail.

View File

@@ -144,8 +144,12 @@ remains in the roadmap until implemented.
ConfigKit owns configuration input processing and the coordination of decoding,
resolution, validation, and inspection. Applications own their configuration
types, field meanings, defaults, normalization, semantic validation, and schema
version policy. Applications also own main-file search conventions, CLI wiring,
remote acquisition, runtime initialization, and resource checks.
version policy. Applications choose and order their main-file search conventions;
ConfigKit provides explicitly selected search descriptions that resolve during
loading. Applications also own CLI wiring, remote acquisition, runtime
initialization, and resource checks. See the [configuration discovery
contract](../config.md#configuration-discovery) and [ADR
0019](../adr/0019-explicit-configuration-discovery.md).
The engine applies each input's mapping destination within the existing source
assembly, applies immediate-parent-relative YAML destinations to imports, and
@@ -157,11 +161,13 @@ placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
the import extension.
Library code must not depend on consuming applications or initialize their
runtime resources. It must not perform implicit network access, discover
application-specific paths, or change process-global state during ordinary `Load`.
runtime resources. It must not perform implicit network access, implicitly
discover application-specific paths, or change process-global state during
ordinary `Load`.
Import discovery is available only through declarations in selected sources:
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
ConfigKit does not search conventional application paths or discover a main
ConfigKit does not search conventional application paths unless the application
explicitly supplies a discovery description, and it never discovers a main
configuration file implicitly. Imports and sibling inheritance share
dependency-ready scheduling within their existing scopes; acquisition order and
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
@@ -201,7 +207,9 @@ 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
must not introduce inheritance controls; source-document controls are interpreted
only at acquisition, before placement, and later application `config` data is
not reinterpreted. 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.
@@ -368,6 +376,11 @@ 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.
For resolution auditing and inspection, eligible registered collections match
entry and final records by identity within each destination. Structural changes
still require container coverage, and this correspondence does not change source
composition or move final validation earlier. Public diff remains sequence-atomic.
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

View File

@@ -11,8 +11,9 @@
ConfigKit's selected grant is LGPL-3.0-or-later, recorded in
[COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3
texts in [LICENSE](../../LICENSE). The [release procedure](../release.md)
owns source-only Go module publication through Gitea. No separate hosted release
object or license asset is required for this channel.
owns source-only Go module publication and, beginning with v1.0.0, local delivery
of a Gitea release page using the complete committed release note. That page
retains the note's licensing footer; separate license assets are not required.
## Purpose And Scope

View File

@@ -23,9 +23,10 @@ policy and link to it rather than introduce additional release requirements.
ConfigKit publishes a source-only Go module. Its declared deliverables are the
immutable annotated source tag and version-matched checked-in release note.
Standalone binaries, container images, hosted release objects, and separate
registry publication are not required. Additional channels require a deliberate
update to the policy and procedure.
Beginning with v1.0.0, maintainers also deliver a Gitea release page containing
that complete note after source publication. Standalone binaries, container
images, and separate registry publication are not required. Further channels
require a deliberate update to the policy and procedure.
The Go API, documented YAML and resolution semantics, and structured diagnostic
contracts participate in compatibility review. A signature-preserving change
@@ -80,9 +81,13 @@ the tagged source. The procedure must identify those deliverables and the
responsibility of their automation. Published deliverables must be traceable
to that source and identify the same version.
ConfigKit currently declares no additional deliverables. Confirmed publication
of the intended source tag is the completion point. CI, macOS validation,
hosted delivery, and consumer retrieval are outside the release gates.
ConfigKit's hosted release page is delivered locally through ReleaseKit after
confirmed source publication, using the tagged note and preserving its licensing
footer. Hosted delivery has a separately reported outcome and may be retried
against the same immutable tag without republishing it. Confirmed publication
of the intended source tag remains the source-release completion point. CI,
macOS validation, hosted-service availability, delivery credentials, and consumer
retrieval are outside the source-publication gates.
## Versioning And Compatibility
@@ -176,8 +181,8 @@ ConfigKit uses the complete checked-in note as the annotated tag message.
Include the release footer defined by the [OSS policy](oss.md#discoverability)
in each new release document. The release procedure defines the concrete URLs
and publication mechanics. Prepare these links with the release note; they do
not add a network check or approval gate. If a hosted download page is used in
a future channel, preserve the footer there.
not add a network check or approval gate. ConfigKit's hosted release page reuses
the complete committed note and its footer.
Handle version selection, release-note content, and retirement of completed
roadmaps during preparation. Preserve planned and deferred work and repair
@@ -208,8 +213,8 @@ test or CI run required before it.
The selected profile runs the complete deterministic, offline default suite
once with the race detector and test-time vet disabled. The suite already
includes examples, consumer fixtures, documentation links, and package
boundaries. One full vet pass adds static checks beyond the test-time subset.
includes examples, consumer fixtures, and package boundaries. One full vet
pass adds static checks beyond the test-time subset.
CGO-disabled Linux amd64 and arm64 package compilation covers supported
configurations distinct from native race compilation. macOS and newer-toolchain
checks are optional.
@@ -221,6 +226,22 @@ expanded platform matrices solely because they are possible. Retain such
checks when their distinct protection justifies their cost. A one-time migration
check must not silently become a permanent release requirement.
ConfigKit selects ReleaseKit's targeted forbidden-file check to keep the local
delivery token path out of the committed candidate. The profile owns the path;
the check does not inspect file contents or Git history. Hosted credential
preflight belongs to delivery and adds no source-publication gate.
ConfigKit delegates committed Markdown link validation to ReleaseKit rather
than maintaining a documentation-link test in the Go suite. Links outside the
checkout remain out of scope. This moves automated link feedback to candidate
validation; ordinary development review still covers edited links.
ConfigKit also selects ReleaseKit's local-replacement check for committed module
declarations. Filesystem replacements can make local validation succeed with
dependencies unavailable to consumers of the published module. Versioned module
replacements remain permitted. This check does not validate remote dependency
availability or require consumer retrieval.
ReleaseKit runs its selected Go commands with readonly module mode and
`GOPROXY=off`; acquire dependencies and build the tool before candidate
validation. Formatting and whitespace remain ordinary development checks in

View File

@@ -1,16 +1,25 @@
# ConfigKit Release Procedure
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
ConfigKit publishes a source-only Go module. The immutable annotated tag and
matching checked-in release note are the deliverables; there is no binary,
container image, generated package, or separate registry publication.
A release publishes the Go source under a new Git tag, with a matching release
note committed in the repository. The tag is annotated: it includes the release
note text and points to the released commit. Published tags and contents are
immutable. There is no binary, container image, generated package, or separate
registry publication. Beginning with v1.0.0, maintainers also deliver a Gitea
release page containing the complete checked-in note after source publication.
Source publication and hosted delivery have separate outcomes.
A **candidate** is the committed revision you intend to release. The
**authoritative remote** is the repository where ConfigKit publishes releases;
its exact push URL is listed below. The **consumer minimum** is the oldest Go
version supported for applications using ConfigKit.
## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
- Module: `go.maximumdirect.net/configkit`
- Primary branch: `main`
- Authoritative remote: `origin`
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
- Authoritative push URL: `ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git`
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
- Minimum Go version: the first stable patch of the selected Go series, or a
higher patch when required by a dependency or specific correctness fix, as
@@ -20,10 +29,16 @@ container image, generated package, or separate registry publication.
- Best-effort targets: macOS amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support)
Release validation runs one offline race-enabled Go suite, one full vet pass,
and CGO-disabled package compilation for both Linux targets. macOS and CI are
Release validation runs the offline Go test suite once with the race detector,
then a full vet pass and package compilation for both Linux targets with cgo
disabled (`CGO_ENABLED=0`), followed by the selected committed-file checks
described in [publication](#4-publish-the-committed-candidate). macOS and CI are
not release gates; do not wait for or poll CI.
The vanity module path is the name applications put in Go imports. Source/tag
browser links and public Go retrieval use the Forge HTTPS repository
independently of SSH publication.
## 1. Select Go and prepare the tool
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
@@ -33,10 +48,10 @@ For a patch release, retain the series recorded in `go.mod` and leave its consum
minimum unchanged. Follow the
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
Put the selected series' latest locally available compatible patch first on
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build
ReleaseKit and run its child validation commands. Set local toolchain selection
and verify it:
Choose the latest compatible patch installed locally in that Go series. Put
its `go` executable first on `PATH` and ensure it meets the minimum in
`go.mod`. The same `go` executable must build ReleaseKit and run its child
validation commands. Set local toolchain selection and verify it:
```sh
export GOTOOLCHAIN=local
@@ -61,8 +76,17 @@ use that required version instead and document the reason. When the series is
unchanged, leave the minimum unchanged unless a minor or major release
deliberately changes it under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
For example, validating with Go 1.26.8 leaves `go 1.26.0` unchanged. There is no
separate requirement to validate with Go 1.26.0.
For example:
| Release situation | Go used for validation | Consumer minimum |
| --- | --- | --- |
| Patch release in the 1.26 series | Latest compatible patch installed locally, such as 1.26.8 | Keep the existing `go.mod` minimum unchanged. |
| Minor/major release staying in 1.26 | Latest compatible patch installed locally | Normally retain the minimum; any deliberate change follows the linked policy. |
| Minor/major release adopting a newer installed stable series | Latest compatible patch installed locally in that series | Normally declare that series' initial stable patch, or the higher patch required by a dependency or identified fix. |
Using Go 1.26.8 for validation can therefore leave `go 1.26.0` unchanged. Users
need the declared minimum, while maintainers validate with their selected patch.
There is no separate requirement to validate with Go 1.26.0.
Prepare dependencies whether or not the minimum changes:
@@ -70,28 +94,35 @@ Prepare dependencies whether or not the minimum changes:
go mod download
```
Build the tool selected by `go.mod` into temporary storage outside the checkout.
Ensure `$TMPDIR` names a system temporary location, not the repository. These
commands never install another ReleaseKit version:
Run the module-selected ReleaseKit tool from the repository root. Go builds
and caches the executable; no temporary executable or launcher is needed.
These commands use the version recorded in `go.mod`:
```sh
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
releasekit_bin="$releasekit_tmp/releasekit"
go build -o "$releasekit_bin" gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version
go list -m go.maximumdirect.net/releasekit
go tool releasekit version
```
The module-selected executable is ReleaseKit v0.4.1. 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
This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the
tool's exit status and publication report; `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
executable first on `PATH` through the publication command.
For hosted delivery, save the Gitea API token in the ignored local file
`.releasekit/gitea-token` with owner-only permissions. The profile uses it when
`RELEASEKIT_GITEA_TOKEN` is unset or blank; a nonblank environment value takes
precedence. The file path resolves from the repository root, and ReleaseKit
trims surrounding whitespace from its contents. Keep credentials out of release
notes, diagnostic labels, command arguments, and commits. The selected
`forbidden_files` check also rejects a committed token at that exact path;
it does not scan other files or history. Source publication uses Git
authentication and does not read or require this token.
Verify access to the authoritative remote without displaying credentials:
```sh
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main
git ls-remote ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD refs/heads/main
```
Publication requires explicit authorization. Local implementation commits do
@@ -99,10 +130,10 @@ not authorize a push or release.
## 2. Prepare and commit the candidate
Choose an unused stable version locally and on the authoritative remote. Compare
the candidate with the previous tag and review exported Go signatures and
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go
version, and platform support. Review maintained consumer examples and the
Choose a stable version that has not been used locally or on the publication
remote. Compare the candidate with the previous tag. Review public Go APIs and
value ownership, YAML and resolution rules, diagnostic contracts, the minimum
Go version, and platform support. Review maintained consumer examples and the
migration guidance. Explain any consumer-minimum increase and required consumer
action under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
@@ -111,7 +142,7 @@ Create `docs/releases/<version>.md`. Its first line must be exactly
`# ConfigKit <version>`, and it must end with the license and attribution footer:
```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/docs/oss/attribution.md)
```
Replace `VERSION` with the full tag and confirm the three target files exist in
@@ -127,7 +158,7 @@ Push only that committed candidate to the literal authoritative URL. This does
not follow local tags:
```sh
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main
git push --no-follow-tags ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD:refs/heads/main
```
ReleaseKit requires authoritative `main` to equal this candidate before it
@@ -139,82 +170,184 @@ candidate to `main`, and invoke validation again.
## 4. Publish the committed candidate
With the authorized candidate already on authoritative `main`, invoke the
module-selected native executable exactly once:
Once publication is authorized and the candidate is on the publication remote's
`main`, run the module-selected ReleaseKit tool once to validate and publish it:
```sh
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
go tool releasekit publish vMAJOR.MINOR.PATCH
```
Replace the placeholder with the selected version. ReleaseKit checks the
effective push URL against `expected_push_url`, verifies the candidate on
push URL Git will actually use, after URL rewrites, against `expected_push_url`.
It verifies the candidate on
`main`, and checks the release-note H1 for the product and version. It then
runs the selected race-enabled standard suite once with test-time vet disabled,
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
arm64. It publishes only the annotated tag for that candidate. Confirmed
arm64. It also checks the committed candidate for the forbidden token path,
filesystem replacements in module declarations, and local Markdown destinations
and supported heading fragments. Markdown links outside the checkout are
reported as out of scope without failing validation. These file checks inspect
the committed candidate, not uncommitted or ignored working-tree content.
It publishes only the annotated tag for that candidate. Confirmed
publication of the intended tag completes the release. The tag points to the
captured commit and uses the complete checked-in note as its annotation message.
The optional `check`
command is available for diagnosis but is not a separate required pass:
Use the optional `check` command for diagnosis when useful. It validates without
publishing and is not required before `publish`, which performs its own checks:
```sh
"$releasekit_bin" check vMAJOR.MINOR.PATCH
go tool releasekit check vMAJOR.MINOR.PATCH
```
Status 0 confirms publication, status 1 reports a known publication failure or
conflict, status 2 reports usage, profile, or version errors, and status 3 means
the push outcome remains unknown. Preserve both the native exit status and its
diagnostics.
For `publish`, interpret the program's exit status as follows:
| Status | Meaning | Next action |
| --- | --- | --- |
| `0` | Publication succeeded. | The source release is complete. Continue with hosted delivery. |
| `1` | A known publication failure or conflict was reported. | Read the report and correct the stated problem. Preserve any published tag. |
| `2` | The command, profile, or version is invalid. | Correct the command or configuration before trying again. |
| `3` | The tag push outcome is still unknown. | Follow the recovery procedure below before retrying or declaring success. |
Keep both the program's exit status and its diagnostic report. Using `go run`
would lose the distinction between its nonzero exit statuses.
The command does not push or repair `main`, wait for CI, or publish binaries or
a hosted release page. A successful intended-tag publication does not require
remote read-back or consumer retrieval; those checks remain optional.
## 5. Recover from a failed or uncertain tag push
## 5. Deliver the hosted release page
The executable reports the publication state, phase, version, candidate commit,
tag-object ID, effective endpoint, and error on standard error. It performs one
bounded authoritative reconciliation after a failed tag push. Exit status 3
means the tag outcome remains unknown; do not blindly retry or move a tag.
For an uncertain result, use the exact endpoint, candidate commit, version, and
tag-object ID from the report. Copy the captured endpoint into the variable
below using normal shell quoting. Check that Git still resolves it to itself,
then query the authoritative refs once:
After source publication succeeds, run local hosted delivery from the repository
root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the
module-selected tool as in step 1. For the first release using this workflow:
```sh
go tool releasekit deliver v1.0.0
```
For subsequent releases, substitute the selected version. The profile selects
Gitea at `https://forge.maximumdirect.net`, owner `base-layer`, repository
`configkit`. With no build targets, delivery creates or accepts a stable,
non-draft release page containing the complete committed note, including its
licensing footer. It creates no binary assets, receipts, or checksum manifest.
An existing page with matching content is accepted; conflicting content is not
overwritten.
Delivery automatically performs an authenticated, read-only repository preflight
before hosted writes. A separate preflight before source publication is not a
release gate. Delivery does not rerun candidate validation, publish a tag, or
wait for CI. Hosted-service availability and delivery credentials do not gate
source publication. Report source-publication success and hosted-delivery status
separately.
| Delivery status | Meaning and next action |
| --- | --- |
| `0` | Delivery is complete; report the hosted release URL. |
| `1` | Preflight was unavailable, or delivery failed, conflicted, or made partial progress. Read the reported state and correct the cause; investigate conflicts before retrying. |
| `2` | Usage, profile, credential loading, or preflight configuration/access failed. Correct the reported problem before retrying delivery. |
| `3` | A hosted write has an unresolved outcome. Inspect the hosted release and resolve that state before retrying. |
A delivery problem leaves the successfully published source tag intact. Retry
delivery with the same immutable version after correcting the cause and
resolving any uncertain write. For a later retry, use a clean checkout of the
original tag, fetching that tag if needed. Use that checkout's module-selected
CLI, prepare its dependencies and tool as in step 1 without changing the tagged
module files, and make the token available there. For example, with the tag
available locally:
```sh
release_version=v1.0.0
git switch --detach "$release_version"
```
After preparing the tool in that tagged checkout:
```sh
go tool releasekit deliver "$release_version"
```
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
If published source or content needs correction, prepare a new appropriate
version through the normal release procedure.
## 6. Recover from a failed or uncertain tag push
ReleaseKit attempts to save publication evidence before pushing a tag and
reports the absolute record path, or a recording warning, separately from the
publication outcome. A recording failure does not undo a successful publication
or change its exit status. Confirmed publication still completes the source
release; hosted delivery remains a separate operation.
Records default to a private store under the repository's Git common directory,
shared by linked worktrees. `publish --record-dir DIR` can select another
location; relative paths resolve from the source repository root. Records can
contain exact endpoints, including credentials. Keep them private and remove
them when their recovery value expires. There is no new tracked storage,
credential-preflight gate, or required recording-success check.
After a failed push, ReleaseKit makes one bounded observation of the original
publication endpoint. If the outcome remains unknown, use the explicitly saved
record rather than rerunning publication. Copy the recovery command printed by
ReleaseKit, or use the module-selected tool:
```sh
publication_record='/absolute/path/from/the/publication/report.json'
go tool releasekit reconcile --record "$publication_record"
```
If the path was lost, discover local attempts without contacting a remote:
```sh
go tool releasekit publication-attempts
```
Select the record for the intended attempt from its reported version, candidate,
and tag evidence; discovery does not automatically choose the latest attempt.
For an alternate store, supply `publication-attempts --record-dir DIR`.
Both commands support optional `--json` output. Treat saved records as private
regardless of the CLI's credential-redacted diagnostic output.
Reconciliation observes the captured endpoint and exact tag object using current
supported Git transport settings. It does not load the current profile, require
the original `HEAD`, rerun candidate validation, change tags, or contact Gitea
hosted delivery. It needs no clean candidate checkout. Use ReleaseKit v0.8.0 or
another CLI supporting the saved record format, including format 2 where exact
non-UTF-8 endpoint/path bytes were retained.
| Reconciliation status | Meaning and next action |
| --- | --- |
| `0` | The intended tag object is published. The source release is complete; continue with hosted delivery from its clean tagged checkout. |
| `1` | The tag is currently absent or conflicting. Read the reported state. Investigate conflicts; absence alone does not prove that the version was never published. |
| `2` | The record cannot be read/decoded, or the invocation is invalid. Correct the path, access, CLI compatibility, or command. |
| `3` | Observation remains unresolved, including insufficient evidence or cancellation. Correct the transport or other reported cause before another explicit observation; do not blindly republish. |
These are recovery actions after failure, not read-back requirements after a
successful push. A failed observation leaves the result unresolved. Do not poll
CI or move a tag to settle uncertainty.
When no usable record exists (for example, storage failed or an older CLI made
the attempt), use the captured endpoint and exact tag-object ID from the original
report for a manual authoritative observation. Checking only the peeled commit
would not establish that the intended annotated tag object arrived. Do not
substitute current profile or local tag state for missing original evidence.
Only after establishing that a retained local tag was never published or used
by another release channel may it be removed before retrying publication:
```sh
authoritative='COPY_CAPTURED_ENDPOINT'
release_version='vMAJOR.MINOR.PATCH'
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
```
A failed URL-equality check or remote query leaves the outcome unresolved.
Correct transport configuration before continuing; a rewritten destination
cannot establish absence at the captured endpoint.
Compare the `main` object ID with the reported candidate commit and the tag
object ID with the reported tag-object ID. `--refs` returns the annotated tag
object itself, not merely its peeled commit. If the tag object matches, the
release is published. If the query fails, the result is conflicting, or the
endpoint or candidate differs, stop and resolve the state before retrying. If
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
the reported candidate, remove a retained local tag only after confirming it
was not published or used by another channel; then rerun `publish`, which
revalidates the candidate:
```sh
git tag -d "$release_version"
"$releasekit_bin" publish "$release_version"
go tool releasekit publish "$release_version"
```
Replace the placeholder with the original attempt's version.
The next `publish` revalidates the candidate and publication guards. A conflicting
published tag must never be moved or reused. A defect in published source
requires a corrected commit, a new appropriate version, and a matching note.
A validation failure after the candidate reached `main` leaves an untagged
candidate there. Correct it with a new commit rather than rewriting or rolling
back `main`. A conflicting published tag must never be moved or reused. A
defect in published source requires a corrected commit, a new semantically
appropriate version, and a matching release note.
candidate there; correct it in a new commit rather than rewriting `main`.
## 6. Optional consumer verification
## 7. Optional consumer verification
When useful or requested, read back the remote tag or retrieve the exact
version into a new module without a local `replace` directive. These checks are
@@ -228,8 +361,8 @@ consumer_dir=$(mktemp -d)
(
cd "$consumer_dir"
go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version"
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit
GOWORK=off GOPROXY=direct go get "go.maximumdirect.net/configkit@$release_version"
GOWORK=off go list -m -f '{{.Path}} {{.Version}}' go.maximumdirect.net/configkit
)
```

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

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

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

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

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

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

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)

144
docs/releases/v1.0.0.md Normal file
View File

@@ -0,0 +1,144 @@
# ConfigKit v1.0.0
ConfigKit's first stable release completes the public API cleanup, moves the Go
module to `go.maximumdirect.net/configkit`, and scopes configuration controls to
the root of each source document. It also corrects presence, history, and
sensitivity reporting and adds direct source lookup for successful and failed
loads.
This release includes intentional breaking changes from v0.10.2. The public Go
API, documented configuration and resolution semantics, and structured diagnostic
contracts now follow the [stable-release compatibility policy](../policy/release.md#versioning-and-compatibility).
That policy includes the existing allowance for minor releases to raise the Go
consumer minimum.
## Upgrade from v0.10.2
### Module path
Update the dependency requirement and Go imports from
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit`,
selecting v1.0.0. The package name remains `configkit`.
Go treats the two import paths as different packages with different named types.
Coordinate the change with dependencies that exchange ConfigKit values with your
application. Earlier tags retain their original module declarations; no old-path
compatibility module is provided. See the
[module migration guide](../consumers/README.md#vanity-module-path-migration).
### Source-document controls
Rename genuine source-root `configkit` control blocks to `config` in main files,
imported fragments, underlays, selected overlays, and programmatic mapping inputs.
The recognized controls remain `imports` and `sequences`, subject to their
existing context-specific rules and import opt-in.
The control name is reserved only at the root of each acquired document. ConfigKit
extracts controls before placing that document's payload. Nested `config` keys,
including those in sequence records, are ordinary application data. Application-root
`config` data produced by placement, defaults, CLI overrides, or resolution is
also ordinary data and is not interpreted again as controls.
Review source-root application fields already named `config`: move, rename, or
separately mount that data. The former names `configkit` and `_configkit` are now
ordinary application keys at every depth, with no control aliases. A permissive
schema can therefore accept an unmigrated control block as data without executing
its imports. Stored source-local declaration paths change from `/configkit/...`
to `/config/...`.
Source-version checks distinguish actual source-local controls from application
paths with the same spelling. Existing inheritance rules remain in place. See
[imports and control scope](../config.md#declaration-and-selection-rules) and
[the directive migration guidance](../config.md#earlier-directive-spelling).
### Public Go API
The following names replace their pre-v1 counterparts outright, without
compatibility aliases:
| Previous API | v1.0.0 API |
| --- | --- |
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
| `Definition.Secret` | `Definition.Sensitive` |
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
| `SourceVersionCheck` | `SourceVersionEvidence` |
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
| `ImportSelection` | `EmptyImportSelection` |
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
| `Paths(paths...)` | `SearchPaths(paths...)` |
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
| `LayerBase` | `LayerMain` |
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
The recording methods describe mutations the application has already performed;
they do not assign values. `Default` and `Normalize` remain the helpers that both
assign and record. `EntryInfo` names the fixed resolution-entry metadata view,
including defaults and CLI assignments. Version evidence records what was
observed, not proof of checker execution or acceptance.
The redundant `ImportSelection.Outcome`, `ImportSelectionOutcome`, and
`ImportSelectionNone` are removed. Empty-selection records retain their selector,
path, and location. The obsolete diagnostic constants `CodeResolveInvalidKind`
and `CodeOptionsUnavailable` are also removed without replacements.
Update stored or compared strings as well as Go symbols:
- The main provenance layer changes from `"base"` to `"main"`.
- The retained-history limit diagnostic changes from
`"explain.limit_exceeded"` to `"history.limit_exceeded"`.
The YAML import options `follow` and `hidden`, the `configkit:"secret"` Go tag,
and typed `Secret` references retain their existing spelling and behavior. See
the [API migration guide](../consumers/README.md#pre-v1-public-api-cleanup) for
the consolidated mapping and usage guidance. Rebuild and run your application's
configuration tests after migrating its imports, API calls, and source controls.
## Source inspection
`Result.Source(id)` and `LoadError.Source(id)` return a detached `SourceInfo`
record and a found flag for an already acquired source. They complement
`Sources()` and the existing callback-scoped `Resolution.Source(id)`. Empty or
unknown IDs return false. Lookup does not reopen inputs or resolve credentials;
failed loads retain the same partial, conservatively redacted evidence as their
source inventory. See [results and failures](../config.md#results-and-failures).
## Correctness and internal improvements
- Resolution presence queries distinguish numeric mapping keys from sequence
indexes using the configuration's entry shape. A later shape change no longer
transfers input explicitness to a different semantic path.
- Metadata history and value explanations now use one shared internal history
model. This corrects attribution across shape changes, preserves container
origins, and avoids duplicate intermediate source-history records.
- Final dynamically typed secret fields retain their sensitivity classification
in inspection output. Parsed dependency paths are deduplicated without losing
distinctions needed for sensitivity propagation.
- Composition-conflict diagnostics preserve claimant order through structured
reporting and rendering, consistent with scoped assembly order.
- History selection and explanation use indexes and reuse prepared selections
and sensitivity dependencies to reduce repeated traversal. Retained-history
accounting follows the shared model, and secret resolution and validation share
traversal machinery.
These changes preserve the documented loading and inspection rules apart from
the explicit migrations above. No end-to-end performance improvement is claimed.
The [configuration contract](../config.md) and public Go declaration comments
remain the behavior references.
## Requirements and release delivery
The consumer minimum rises from Go 1.26.0 to Go 1.27.0. Consumers must upgrade
their Go toolchain before adopting this release. The YAML dependency is unchanged.
Linux amd64 and arm64 remain the release-validated targets; macOS support remains
best-effort under the [platform support policy](../policy/release.md#platform-support).
Release tooling moves to `go.maximumdirect.net/releasekit` v0.6.0. Beginning with
this release, the source tag and checked-in note are accompanied by a Gitea release
page containing the complete note and licensing footer. Source publication and
hosted delivery remain separate outcomes under the [release procedure](../release.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/oss/attribution.md)

79
docs/releases/v1.1.0.md Normal file
View File

@@ -0,0 +1,79 @@
# ConfigKit v1.1.0
ConfigKit now accepts field-level resolution records inside lists and follows
registered records across position changes when reporting their provenance and
history. This release also standardizes more release validation on ReleaseKit.
## Field-level resolution in lists
Applications can normalize or derive a field inside a list and record that
field directly. For example, changing `/resources/0/path` can be covered by
`RecordNormalization` at that path, without recording the entire `/resources`
list when no other changes require coverage.
For ordinary lists of equal length, resolution auditing compares corresponding
positions recursively. Positions are addresses; ConfigKit does not infer an
element's identity from similar values. A length change still requires a record
for the list or an ancestor.
For collections registered through `Definition.SequenceIdentities`, ConfigKit
matches records between the resolution-entry and final snapshots when both
endpoints have valid, unique identities. Field edits can be recorded individually.
Changes to identity order or membership still require list or ancestor coverage;
changing an identity is treated as removal and addition. When matching is
unavailable, a changed registered collection is audited as a whole. Existing
final identity validation remains in place, allowing resolution to repair entry
values.
See [recorded application resolution](../config.md#recorded-application-resolution)
for the complete rules.
## Matched history and inspection
For eligible registered collections, inspection at a surviving record's final
position follows its own entry evidence, including earlier values, input
explicitness, and source attribution. A record no longer inherits the evidence
of a different record that previously occupied its final position. Original
source coordinates remain intact, while reported operation paths follow the
matched final record.
Collection inspection retains removed records' evidence. Removed fields of
surviving moved records remain available through metadata history and
explanation, including deep inherited fields. Sensitivity propagation protects
matched and removed evidence and recorded dependencies; detached before/after
snapshots remain conservatively masked.
Correspondence lookup, sensitivity propagation, and resolution-change ordering
reuse indexes, a work queue, and prepared pointer strings to avoid repeated
prefix construction, full-map passes, and formatting during sorting. No
end-to-end performance improvement is claimed.
## Compatibility
This is a backward-compatible extension of resolution auditing and inspection.
Existing callbacks that record a whole list remain supported. Records and helper
targets for surviving values refer to final positions; helpers do not follow
later moves automatically. `Resolution.EntryInfo` and `Resolution.IsExplicit`
retain their fixed entry-snapshot meaning.
Public effective-configuration diffs continue to report list changes atomically.
Source composition, collection merge operations, configuration syntax, and public
path addressing are unchanged. No new public Go symbols are required.
The consumer minimum remains Go 1.27.0, and the YAML dependency is unchanged.
Linux amd64 and arm64 remain release-validated; macOS remains best-effort.
## Release tooling
ReleaseKit advances from v0.6.0 to v0.8.0. The runbook uses the module-selected
`go tool releasekit` command and documents saved publication attempts and
reconciliation after failed or uncertain tag pushes.
ReleaseKit now checks committed Markdown links and rejects filesystem dependency
replacements during candidate validation. The custom documentation-link Go test
is removed, so automated link feedback occurs during candidate validation rather
than ordinary Go tests. Source publication and hosted Gitea delivery remain
separate outcomes; CI remains outside release gates. See the
[release procedure](../release.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/oss/attribution.md)

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,998 +0,0 @@
# Sequence Identity And Combination Roadmap
Status: settled feature roadmap; implementation is specified in the
[staged implementation plan](implementation.md). This document defines the
targeted end state, not an implemented contract. The implementation plan owns
execution order, exact Go names and signatures, bookkeeping, and validation.
## Purpose And Scope
Allow configuration authors to assemble collections from independently authored
records, declare identity in configuration, and progressively extend a baseline
collection through higher layers. Authors can replace the entire collection,
append distinct records, or replace complete records by identity while retaining
unmatched records. Sibling inheritance uses the same sequence operations and
identity-based defaults. Within an identified sequence, a record can explicitly
inherit settings from another record in that sequence while keeping its own
identity.
The motivating use case is a prompt catalog whose source-backed baseline supplies
records and whose application or installation layers add or supersede records.
Applications can optionally impose identity constraints that configuration must
respect and that hold in the final loaded configuration. These constraints can
cover exact destinations or collections beneath dynamically named mapping keys,
without requiring the application to discover those names before loading.
The feature covers ordinary and layered loading, imported items, inline
sequences, selected overlays, and sibling inheritance. Typed defaults, CLI
assignments, and application resolution retain their own combination semantics;
CLI application moves later in the loading lifecycle.
Automatic recursive merging of matching records remains deferred
(see [future work](future.md#1-recursive-body-merging-for-identified-records));
explicit inheritance between distinct records is included. The initial
declaration surface is `configkit.sequences`; `key` and `mode` shorthand on
imports is deferred. Application lists remain
ordinary YAML sequences without control wrappers. Unkeyed sequence concatenation
is outside this feature.
## Current Baseline And Required Changes
The [import contract](../config.md#explicit-imports) owns existing selectors,
destinations, placement, and ordering. `placement: sequence-item` already places
each selected source mapping as one item at its effective destination. Item-local
helper imports can contribute fields before placement.
The [composition contract](../config.md#strict-document-composition),
[layered-loading contract](../config.md#layered-loading), and
[inheritance contract](../config.md#sibling-inheritance) describe the implemented
baseline:
| Area | Current behavior | Target behavior |
| --- | --- | --- |
| Identity declaration | `Definition.SequenceCompositions` registers one literal string field at each registered mapping path. | YAML collection declarations and optional authoritative application registrations support field sets and scalar identity components. |
| Application registration destinations | Registrations select exact literal mapping paths. | Exact and structural selectors can impose identity beneath arbitrary mapping keys, independently at each matched destination. |
| Strict composition | Registered sequences append distinct string identities and reject duplicates; repeated unregistered sequences conflict. | Collections with effective identity append distinct records and reject duplicates. Applicable lower-tier identity is available before composing higher-tier peers. Unidentified sequences retain strict-conflict behavior. |
| Source precedence and selected overlays | A present higher sequence replaces the lower sequence wholesale. | Collection policy selects `replace`, `append`, or `upsert`; overlays can declare policy for inline sequences. |
| Sibling inheritance | An inheriting mapping recursively overlays its resolved parent; supplied sequences replace wholesale. | Mappings retain recursive combination, while sequences use destination policy and the same three operations. |
| Sequence-member inheritance | A sequence element has no sibling scope for its own inheritance relationship. | An identified sequence provides a sibling scope: a record can inherit from one record in the same sequence, selected by complete identity. |
| CLI overrides | Apply before inheritance and typed defaults/decoding. | Apply after inheritance and typed defaults/decoding, before application resolution. |
| Identity enforcement | Registration governs strict sequence composition. | Structural identity checks cover identified combinations; application registrations additionally constrain final effective sequences. |
Source-backed underlays participate in source assembly. `Definition.Defaults`
provides typed defaults later; extending source combination does not turn typed
default lists into source underlays.
## Collection Declarations
Use a source-root `configkit.sequences` block containing collection declarations.
Each entry identifies a `destination` and may declare `key`, `mode`, or both.
Identity and mode can be supplied independently. This form serves imported items,
inline records, ordinary mapping imports, and inline sequences in selected
overlays.
```yaml
configkit:
sequences:
- destination: [prompts]
key: [namespace, id]
mode: append
imports:
- dir: prompts
pattern: '*.yaml'
destination: [prompts]
placement: sequence-item
```
Each selected file contributes a record such as:
```yaml
namespace: support
id: welcome
text: Welcome
```
Here append retains a lower-layer collection and adds distinct higher-layer
records. Identity and combination controls belong to the collection declaration,
so imported and inline contributions share one policy surface.
### Destinations And Scope
`destination` uses the existing sequence of literal mapping keys. Resolve it
relative to the declaring source's placement: a fragment mounted under `catalog`
can declare `[prompts]` locally. A sequence-item source can declare a nested
collection within its own item without referring to an eventual numeric index.
Preserve source and item-local scope boundaries.
A collection's scope is its resolved destination within its composition scope.
Resolve relative destinations before reconciling declarations. Different imported
items retain separate scopes for their nested collections. A declaration governs
all contributions to its collection in the tier, including imported and inline
records; it does not govern only records in the declaring file.
Gather declarations before composing the relevant scope so their effect does not
depend on which contribution is assembled first. Retain applicable controls
through source precedence and inheritance, then keep them out of application
decoding.
Sequence declarations are independent of filesystem import enablement. Selected
overlays may contain `configkit.sequences`, extending the current blanket
prohibition on overlay `configkit` declarations. This does not enable imports
in overlays.
### Overlay Authoring
For an existing sequence `[foo, bar]`, an overlay can replace `foo` in full:
```yaml
configkit:
sequences:
- destination: [prompts]
key: id
mode: upsert
prompts:
- id: foo
text: Revised definition
```
The result is `[foo*, bar]`. The explicit upsert mode can be omitted when no
different explicit mode applies from a lower tier: effective identity then
selects upsert. To add a distinct record instead:
```yaml
configkit:
sequences:
- destination: [prompts]
key: id
mode: append
prompts:
- id: baz
text: Another prompt
```
The result is `[foo, bar, baz]`. To replace the whole sequence:
```yaml
configkit:
sequences:
- destination: [prompts]
mode: replace
prompts:
- id: bar
text: Bar definition
- id: baz
text: Baz definition
```
The result is `[bar, baz]`. Explicitly selecting the default operation is valid.
With no applicable explicit mode, omitting this declaration selects replace for
an unidentified collection and upsert for an identified one. A retained explicit
mode continues to govern.
## Identity Definitions And Constraints
### Field Sets
`key` accepts a string or a nonempty list of strings naming direct, literal
fields in each record. A name such as `metadata.id` denotes that literal field,
not a nested lookup. Treat field names as a set: remove repeated names and
ignore authored order when comparing definitions.
Thus `id`, `[id]`, and `[id, id]` are equivalent; `[namespace, id]`,
`[id, namespace]`, and `[namespace, id, id]` are also equivalent. An empty list
is invalid because it supplies no identity fields. These rules apply to both
configuration declarations and application registrations.
Use a consistent field order internally to construct identity tuples. Preserve
component types and tuple boundaries so unstructured concatenation cannot cause
otherwise distinct identities to collide.
### Scalar Components And Equality
Each identified record must be a mapping with every designated identity field
present. Components may be strings, numbers, booleans, or explicit null. Missing
fields and mapping or sequence components are invalid. Present null differs
from absence; two single-field null identities are equal. Compound identities
are equal when all corresponding components are equal.
Strings compare exactly, including empty strings, without trimming, case folding,
or normalization. Scalar categories remain distinct: `"1"`, `1`, `true`, and
null are different identities.
Numbers compare by mathematical value across integer, fractional, and exponent
spellings: `1`, `1.0`, and `1e0` are equal, as are `0` and `-0`. Use exact numeric
normalization for identity comparison without rounding through a floating-point
conversion. This does not rewrite authored values or change existing typed
decoding and projection semantics. The parser's rejection of non-finite numeric
forms remains unchanged.
### Application-Owned Constraints
Retain an optional application interface for registering collections and imposing
their identity definitions. Configuration may define identity where no application
constraint exists. When a declaration omits `key`, an applicable registration
supplies identity. An explicit equivalent definition is accepted, and a different
definition fails under every mode, including replace.
For example, if the application requires `id`, `key: [id, name]` is invalid:
that compound identity would allow repeated `id` values with different names.
A registration is an authoritative constraint, not an omission-filling default.
Copying a collection through inheritance does not create another application
registration; registrations constrain their registered destinations.
Identity constraints validate actual sequences. They do not independently require
a path to exist, be non-null, or have sequence shape. The application schema and
validation own those requirements. Final-result enforcement for application
registrations is defined under [Validation And Diagnostics](#validation-and-diagnostics).
### Application Registration API
Use one public sequence-identity registration type, collected in one field on
`Definition`. Each registration contains an explicit destination selector and
its identity fields. Exact and dynamic registrations share matching, overlap
validation, identity comparison, and lifecycle behavior.
Provide two constructors returning that same registration type:
- An exact-path convenience constructor accepting a literal `Path` and identity
field names. It converts the path into a selector containing literal segments.
- An explicit-selector constructor accepting a structural selector and identity
field names, for destinations that include arbitrary mapping keys.
Both constructors accept identity fields as variadic strings, so one-field and
compound identities use the same construction form. Apply the settled field-set
rules, including deduplication. Invalid registrations, including an empty identity
field set, fail during application-definition validation.
Existing `Path` values retain their literal meaning throughout ConfigKit. The
exact-path constructor supplies convenience at the registration boundary; dynamic
matching uses the explicit selector type. Both forms can appear together in the
same definition field.
Replace the current registration shape with this model. Preserving the existing
`SequenceComposition` fields and `Definition.SequenceCompositions` spelling is not
a requirement, and no compatibility facade is needed solely to retain that syntax.
Choose names centered on sequence identity, reflecting its role in composition,
inheritance, and final validation. Exact Go type, field, and constructor names
and signatures belong in the implementation plan.
### Dynamic Application Registration Destinations
Allow applications to register identity for collections whose containing mapping
keys become known only during loading. For example:
```yaml
collections:
weatherreporter:
prompts:
- id: foo.bar
text: Report the weather.
homewatch:
prompts:
- id: foo.bar
text: Report the household status.
```
One application registration can require `id` for every namespace's `prompts`
list. The equal IDs above are valid because each list has its own uniqueness
scope. An optional root `prompts` list can have a separate exact registration.
Use an explicit application-side structural selector composed of literal mapping
keys and an arbitrary-mapping-key segment. Conceptually, the namespace rule is:
```text
Literal("collections") -> AnyMappingKey -> Literal("prompts")
```
This notation explains the selector; it is not a Go signature or YAML syntax.
Each segment consumes exactly one mapping level. Match the complete destination,
not an arbitrary prefix or suffix. Literal keys remain exact, including empty
strings and keys containing punctuation such as `*`, `/`, or `.`. An arbitrary-key
segment is distinct from any literal spelling and does not match a sequence
index. Existing `Path` values retain their literal meaning.
This capability belongs to the reusable application definition and applies to
ordinary and layered loads. YAML collection destinations and import destinations
retain their existing literal syntax. Recursive search, regular expressions,
filesystem glob rules, sequence-index selectors, and custom matching callbacks
are outside this addition.
At each concrete matched destination, apply the same authoritative identity
constraint as an exact registration. Use the settled field-set and scalar-equality
rules. Do not pool identities across destinations or require records to repeat
the containing mapping key as an identity component. Applications retain ownership
of namespace interpretation, qualified runtime IDs, and domain validation.
Matching supplies effective identity before relevant strict composition and
precedence operations. Without an applicable explicit mode, this identity selects
upsert under the existing default rule. It also supplies identity for inheritance
within the matched sequence. YAML declarations must agree with it under every
mode, including replace. These are ordinary application constraints with a broader
destination selector, not a separate final-validation facility.
### Registration Overlap
Equivalent application constraints coalesce at any shared destination. Equivalence
uses the identity field-set rules: for example, `id` and `[id, id]` agree.
Incompatible constraints fail; exact registrations do not override broader
selectors, and registration order grants no precedence.
Detect incompatible overlapping registrations while validating the application
definition, before source acquisition. For these fixed-depth mapping selectors,
overlap exists when the selectors have the same number of segments and every
corresponding pair can match a common key. Unequal literal keys at any position
make the selectors disjoint; an arbitrary-key segment can overlap a literal or
another arbitrary-key segment. Compare segment structure rather than rendered
path strings. No configuration pre-read is needed.
Thus an `id` rule for all namespace prompt lists conflicts with a `[id, name]`
rule for one named namespace, even if that namespace is absent from a particular
load. This is an invalid application definition. A valid selector matching no
collections is harmless: registration does not require a path to exist, create
collections, or impose shape and nullability requirements.
### Matching Across The Loading Lifecycle
Match application selectors against effective configuration destinations after
accounting for input placement. Physical filenames and source-local paths do not
determine applicability. Make constraints available before composing contributions
at those destinations, including namespaces first introduced by imports, mounted
inputs, or higher tiers. Apply matching constraints to key-only YAML declarations
as well, even before their sequence exists. Consumers need neither a preliminary
load nor source inspection to enumerate namespaces.
During inheritance, apply registrations at the inheriting destination, including
newly created matching destinations. Copying a collection does not transfer its
application registrations to a different path. The existing YAML-policy ownership
and transfer rules remain separate from application selector matching.
During final validation, find matching actual sequences in the final effective
configuration. This includes namespaces introduced or replaced by typed defaults,
CLI overrides, and application resolution. Reusing only destinations found during
source assembly would miss these values. Use the same selector meaning and
identity validator in both phases; later assignments do not trigger composition
or inheritance again. YAML-only identities retain their existing enforcement
boundary.
## Strict Composition And Precedence Operations
### Compose Peers Before Applying Precedence
Resolve applicable identity and mode policy, strictly compose the tier's peer
contributions, then apply one selected operation per collection against the
accumulated lower value. The tier operation does not sequentially replace peer
source contributions. Selected overlay contributions use the same combination
semantics.
Within a strict tier, equivalent identity definitions coalesce and different
field sets fail. Agreeing explicit modes coalesce; conflicting explicit modes
fail. Omission expresses no preference. A root declaration or a later declaration
has no precedence over conflicting peers. Explicitly selecting a default mode
is still a real instruction and can conflict with another explicit mode.
Identified peer sequences append distinct records. Duplicate identities among
peers or within a contributed list fail under all three modes, including replace
and upsert. Equal or disjoint record payloads do not excuse a duplicate identity.
Unidentified sequences retain their current strict-composition conflict behavior.
For example, higher-tier fragments `[C]` and `[D]` with an agreed identity assemble
as `[C, D]`. Against lower `[A, B]`, append or upsert produces `[A, B, C, D]`,
while replace produces `[C, D]`. Peers `[B]` and `[B*]` conflict even under
upsert because neither has higher precedence.
### Available Operations
Use one spelling for each mode: `replace`, `append`, and `upsert`.
| Mode | Behavior at source-precedence and inheritance boundaries |
| --- | --- |
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
| `append` | Retain lower records and append higher records; duplicate identities fail. |
| `upsert` | Replace matching records in full, retain unmatched lower records, and append new identities. |
Append and upsert require effective identity. ConfigKit does not infer identity
from an `id` field or record position. Whole-sequence replacement does not require
identity agreement with lower YAML policy, but application constraints still apply.
Letters denote record identities; `B*` has the same identity as `B` with a different
payload:
| Lower | Higher | Mode | Result |
| --- | --- | --- | --- |
| `[A, B]` | `[C]` | `append` | `[A, B, C]` |
| `[A, B]` | `[C]` | `replace` | `[C]` |
| `[A, B]` | `[B*]` | `append` | Duplicate-identity error |
| `[A, B]` | `[B*]` | `replace` | `[B*]` |
| `[A, B]` | `[B*]` | `upsert` | `[A, B*]` |
| `[A, B]` | `[B*, C]` | `upsert` | `[A, B*, C]` |
Upsert replaces the whole matched record. If lower `B` has `id`, `text`, and
`temperature`, while higher `B` has only `id` and `text`, the result at this
boundary has only `id` and `text`. Subsequent defaulting or other lifecycle
operations retain their own semantics. Mappings continue to combine recursively;
matching sequence records do not recursively merge fields.
### Mode Selection And Persistence
Select the operation in this order:
1. Reconcile explicit modes within the current tier; conflicting peers fail.
2. Use the current tier's explicit mode if supplied.
3. Otherwise retain an applicable explicit mode from a lower tier.
4. With no explicit mode, infer upsert when effective identity is available.
5. Otherwise infer replace.
Effective identity can come from an application registration, current declarations,
or applicable lower policy, subject to the identity agreement and lifetime rules
below. Determine the inferred mode from available identity before applying the
operation. A plain higher-tier sequence can therefore use upsert without repeating
a key declaration.
An explicit mode persists at its applicable destination until a higher-priority
explicit declaration changes it. New sequence values alone do not reset it, and
an unrelated overlay leaves it intact. A mode-only declaration can change policy
without supplying records; it neither clears the collection nor retroactively
recombines earlier tiers. Later source contributions and inheritance use the
resulting policy.
| Lower explicit mode | Higher declaration | Effective mode |
| --- | --- | --- |
| `append` | Mode omitted | `append` |
| `append` | `upsert` | `upsert` |
| `upsert` | `replace` | `replace` |
| None | Mode omitted, effective identity available | Inferred `upsert` |
| None | Mode omitted, no effective identity | Inferred `replace` |
Only explicit modes persist. Inferred defaults are recomputed from effective
identity when needed. Explicit replace continues to apply in subsequent tiers
until another mode is declared; introducing identity alone does not override it.
Mode persistence does not preserve YAML identity discarded by replacement, nor
policies owned by discarded records. Source precedence and inheritance use the
same destination policy, without separate per-phase settings. CLI assignments
remain outside mode selection.
### Presence, Empty Sequences, Null, And Kind Mismatches
At a collection's precedence boundary:
| Higher contribution | `replace` | `append` / `upsert` |
| --- | --- | --- |
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
| Explicit `[]` | Clear the sequence. | Contribute no records. |
| Explicit null or another non-sequence value | Ordinary replacement, subject to application schema. | Error: the operation requires a sequence. |
A declaration alone does not clear a collection. An optional import selecting
nothing contributes nothing and does not erase existing records. An absent lower
collection can be initialized by an incoming sequence under any mode, subject to
identity requirements. Explicit lower null or another non-sequence value is not
absence: append and upsert fail against it, while replacement remains available.
An empty incoming sequence bypasses neither kind checks nor identity requirements.
With effective identity and no explicit mode, `prompts: []` uses upsert and does
not clear the collection. Clearing requires replace, selected in the current tier
or retained from lower policy. Replacing an identified collection with null also
requires replace and a schema permitting null. Collection null is distinct from
a present null identity component inside a record.
### Ordering
Retain existing dependency-ready source scheduling within each tier and authored
item order within lists. Gathering declarations does not change assembly order;
identity values do not sort records.
- Append keeps lower records in order, followed by higher records in assembled order.
- Replace keeps only higher records in their assembled order.
- Upsert keeps a matched record's lower position and appends new identities in
higher-tier order.
For example, lower `[A, B, C]` and higher `[C*, A*, D]` produce `[A*, B, C*, D]`
under upsert. Whole-sequence replacement gives the higher tier control of the
complete sequence order.
## Policy Agreement, Lifetime, And Ownership
### Identity Agreement And Replacement
Append and upsert use one agreed identity for all participating records:
| Lower identity | Higher declaration | Result when combining records |
| --- | --- | --- |
| `id` | Omitted | Use `id`. |
| `id` | `id`, `[id]`, or `[id, id]` | Accept the equivalent definition. |
| `id` | `[namespace, id]` | Error: different definitions. |
| None | `id` | Establish `id` and validate all participating records. |
Introducing identity for an unkeyed lower sequence requires checking both lower
and incoming records, including uniqueness within each contribution. With no
identity from the application or either participating collection, append and
upsert fail.
YAML identity follows the collection that survives an operation:
| Operation | Effective YAML identity |
| --- | --- |
| Append or upsert | Retain or establish the agreed identity. |
| Whole-sequence replacement | Discard lower YAML identity and use the replacing tier's identity declaration, if any. |
| Replacement by null or another non-sequence value | Discard lower YAML identity and retain any identity explicitly declared by the replacing tier. |
Application constraints remain authoritative under every operation. Without an
application constraint, replacement can introduce a different identity, such as
changing from `id` to `name`. Replacing a sequence without a new key declaration
or application registration produces an unkeyed sequence. No identity-removal
directive is needed.
The reset occurs only when the collection value is actually replaced. Declaring
replace without supplying a replacement does not authorize changing the identity
of retained records. Explicit mode lifetime is separate from YAML identity
lifetime.
### Ancestor Replacement
Replacing an enclosing mapping can remove a collection without replacing that
collection directly. Apply the same identity and ownership rules to this removal:
discard the removed collection's lower YAML identity, and discard policies owned
by any removed records. Reintroducing the same path later does not restore those
discarded policies.
Independently declared explicit modes retain their existing persistence rules.
Removing a descendant collection does not remove a mode whose declaring scope
survives. Application registrations remain applicable at matching destinations,
including destinations reintroduced later. Identity newly declared by the
replacing tier follows the existing declaration and ownership rules.
For example, a root-level declaration establishes `key: id` and `mode: append`
for `[catalog, prompts]`. A higher tier replaces `catalog` with null, removing
the prompts collection and its YAML identity. The independently declared append
mode survives. If a later tier recreates `catalog.prompts`, append requires
identity supplied by a new declaration or an application registration. Without
that identity, loading fails; the later tier can instead explicitly choose
replace. The replacement of `catalog` itself follows ordinary ancestor-value
precedence and does not attempt to append null to the removed prompts list.
### Declarations Without Sequence Values
A key declaration without accompanying records applies immediately to an existing
retained sequence: check agreement and validate its records, without recombining
earlier tiers. For example, a higher tier that only declares
`destination: [prompts], key: id` identifies and validates the retained lower
`prompts` sequence.
If no collection exists, retain the declaration in its owning scope for later
contributions. An otherwise empty underlay can establish identity for main-tier
fragments. An explicit identity declaration can also remain pending at a
non-sequence destination; it does not independently impose a shape requirement.
The presence and kind rules still determine whether an attempted operation fails.
Item-local declarations retain their ownership boundaries.
### Lower-Tier Identity During Strict Composition
Resolve applicable identity before composing a higher tier's peers, using
application registrations, available lower-tier policy, and declarations gathered
within the higher tier's scope. Authors need not repeat a baseline key solely
because the higher tier has multiple fragments.
For example, an underlay establishes `key: id` for `prompts`, and two main-tier
fragments contribute `[foo]` and `[bar]` without a key declaration. They strictly
compose as `[foo, bar]` before the main tier combines with the accumulated lower
collection. If both contribute `foo`, composition fails; upsert does not give
precedence to peers.
Lower YAML identity remains subject to agreement, replacement, and ownership.
A replacing collection must not be constrained by obsolete lower identity just
because policy resolution precedes composition. Likewise, a replacement record
cannot borrow nested identity owned by the discarded lower record, even when
outer identity or numeric position matches. Application constraints still apply.
Higher-tier declarations do not retroactively repair invalid lower-tier composition.
### Nested Record Ownership
Attach item-local policies to their owning contributions so they follow surviving
records independently of numeric indexes. Whole-record upsert discards the old
record's local policies along with its fields. Whole-sequence replacement discards
policies owned by removed records. A replacement record supplies its own local
declarations; equal outer identity or final index does not transfer discarded
policies.
For example, a source imported as one record into `prompts` can declare:
```yaml
configkit:
sequences:
- destination: [steps]
key: id
mode: append
id: foo
steps:
- id: prepare
```
The declaration governs `steps` inside that `foo` record. Retaining `foo` retains
its local policy, including the explicit mode. Upserting a new `foo` discards the
old `steps` and its declaration even though the outer record keeps its position.
Policies independently applicable at a destination are not discarded merely
because an individual record is replaced. Ownership determines which declarations
are removed. Application registrations remain constraints at their registered
destinations. Inheritance copies transfer identity and choose operations as
specified next.
## Sibling Inheritance
Complete source-tier assembly before resolving inheritance. Retain existing
mapping-sibling scopes and add identified-sequence scopes as described below.
Both use the existing dependency-ready algorithm. At each inheritance combination,
the resolved parent's collection is the lower value and the inheriting mapping's
local collection is the higher value. Mappings combine recursively; upsert within
an identified sequence remains whole-record replacement. Explicit record
inheritance is a separate construction step after those replacements.
Use the inheriting destination collection's policy: an applicable explicit mode,
including one retained through source tiers, wins. Otherwise infer upsert with
effective identity and replace without it. There is no separate default-append
rule for inheritance. The same destination policy governs source-precedence and
inheritance combinations.
YAML identity follows a collection copied through inheritance, subject to agreement
and replacement rules. A source collection's mode does not automatically become an
instruction at a different inheriting destination. For example, source `steps`
has `key: id` and `mode: append`; an inheriting mapping supplies local `steps`
without an applicable explicit mode. The copied identity selects upsert there.
An explicit destination append instead selects append. A matching destination key
agrees; a different key conflicts when combining records, while explicit replace
can establish new YAML identity subject to application constraints.
For example:
```yaml
configkit:
sequences:
- destination: [parent, items]
key: id
child:
items:
- id: foo
value: A
- id: bar
value: B
parent:
extends: child
items:
- id: baz
value: C
```
Despite the names, `parent` inherits from `child`. Identity at `[parent, items]`
selects upsert, producing `[foo, bar, baz]`. If the local sequence instead supplies
a revised `foo`, the result is `[foo*, bar]`. Append rejects that repeated identity;
replace retains only the local sequence.
The same ordering, presence, and kind rules apply at inheritance boundaries.
An omitted local sequence retains the inherited sequence. Explicit `[]` clears it
under replace and contributes no records under append or upsert. Retain controls
until inheritance uses them, then exclude them from application decoding.
### Inheritance Between Identified Sequence Records
An identified sequence supplies a sibling scope for its records. Identity can
come from an application registration or an applicable YAML declaration. A record
may inherit from one other record in that same sequence using an `extends`
mapping that selects the parent's complete identity:
```yaml
configkit:
sequences:
- destination: [prompts]
key: id
prompts:
- id: base
model: standard
temperature: 0.5
- id: concise
extends: {id: base}
temperature: 0.2
```
The final `concise` record is:
```yaml
id: concise
model: standard
temperature: 0.2
```
The `base` record remains in the collection. Parents are ordinary records subject
to the same decoding and validation as other records; there is no abstract
template category.
Use mapping-form references for both single and compound keys. With
`key: [namespace, id]`, a reference can be
`extends: {namespace: shared, id: base}`. Match components using the collection's
field-set and scalar-equality rules, including mathematical numeric equality.
`extends: {id: null}` selects a null-valued identity; `extends: null` cancels a
relationship.
Parent-reference mappings use ordinary strict mapping composition. Item-local
helper imports may supply disjoint fields of a compound reference; overlapping
fields conflict, even when equal. For example, a helper's
`extends: {namespace: shared}` and the importing record's `extends: {id: base}`
compose into `extends: {namespace: shared, id: base}`. This assembles one record;
it does not permit strict peers to contribute duplicate record identities.
Before parent lookup, require the surviving effective reference mapping to contain
exactly the collection's identity fields, with supported scalar values. Missing
or extra fields fail. Validate the completed reference during inheritance, rather
than requiring each helper contribution to be independently complete. Superseded
references remain historical evidence; earlier strict-composition conflicts
remain errors.
Every record must supply its complete own identity before inheritance. Preserve
the child's identity fields and inherit only the parent's nonidentity payload;
inheritance cannot supply a missing child identity. Child payload fields take
precedence, mappings combine recursively, and nested sequences use the inheriting
destination's collection policy. Consume the record's directive before decoding.
Support one parent within the same identified sequence, including forward
references and chains. Missing parents and cycles, including self-reference,
fail the load. Cross-collection references, position-based references, multiple
parents, and inheritance between unidentified sequence members are outside this
scope. Existing string-form references between mapping siblings retain their
meaning; they do not become shorthand for sequence identity references.
Resolve relationships against records surviving source-tier assembly and selected
overlays. A higher layer can replace a parent before inheritance runs; removing
that parent makes a surviving reference fail. Whole-record upsert discards the
old record's directive along with its payload. Omitting `extends` in the
replacement record does not retain the displaced record's relationship. An
explicit reference to the record's own identity is a cycle, not a way to access
its lower-tier predecessor.
Reuse Kahn's algorithm with the existing stable canonical semantic-pointer key
for dependency-ready ordering. Build parent lookup from the sequence's effective
identity, and preserve the assembled list order independently of resolution
order. Enclosing relationships resolve before nested relationships. Parent
templates retain nested instructions for evaluation at each inheriting
destination, following the existing destination-local inheritance rule. Copied
collection identity, destination operation selection, and record-owned policy
transfer follow the rules above.
Record inheritance runs in the existing structural phase, before typed defaults,
decoding, CLI overrides, and application resolution. Those later stages do not
trigger another inheritance pass. Preserve inherited presence, origin and
history evidence, sensitivity, declaring resource contexts, and existing
expansion budgets just as for mapping-sibling inheritance. Identity lookup must
not expose identity values in diagnostics. Existing index-based inspection paths
remain sufficient; no new identity-selector path API is needed.
## Loading Lifecycle
Use the following order:
1. Assemble source tiers and selected overlays, resolving policy before each
tier's strict composition and applying collection operations at precedence
boundaries.
2. Resolve sibling inheritance in mappings and identified sequences, then apply
typed defaults and decode the assembled configuration.
3. Apply explicit CLI overrides to that configuration and check supplied values
against the application schema.
4. Run application resolution against the overridden configuration.
5. Resolve effective secret references, validate the final configuration including
application-registered identities, and publish it.
### Typed Defaults And Application Resolution
Source-backed underlays, including application-supplied mapping sources,
participate in sequence combination. Typed `Definition.Defaults` remains the
initial decoded state: supplied sequences replace default sequences. Source
append does not append to a typed default list.
Application resolution retains assignment and recorded-transformation semantics.
ConfigKit does not automatically combine a callback's assigned sequence with its
previous value. Application-registered identities receive final validation
independently of how defaults, CLI overrides, or callbacks produce their values.
### CLI Precedence
CLI overrides are the highest-precedence input to application resolution.
Supplied sequences replace whole sequences, including sequences inside supplied
mappings. Mappings retain recursive override behavior; empty sequences and
permitted nulls retain explicit replacement meaning. Configuration append or
upsert does not reinterpret CLI assignments. Inheritance and defaulting do not
run again afterward: overriding assembled `[A, B]` with `[C]` presents `[C]` to
application resolution without inheritance reintroducing prior records.
Application resolution sees CLI values and their explicit-presence and source
metadata. `Resolution.Default` preserves explicit CLI values; normalization and
recorded application transformations may still change them. For example, a derived
URL can use a CLI hostname, while an explicit CLI URL can be preserved by an
absence-based default. Retain recording and provenance requirements for those
transformations.
Reject structural directives such as `extends` and `configkit` in CLI override
paths or supplied values. CLI input supplies application values after structural
processing; it cannot supply or cancel inheritance directives, change completed
relationships, or prevent earlier inheritance failures.
Typed decoding also precedes CLI application, so a CLI replacement cannot repair
an earlier decoding failure. A source string in an integer field fails before a
valid CLI integer can apply. This changes current override-before-decoding
behavior. Semantic validation remains after overrides and application resolution.
Secret lookup uses effective references after overrides and resolution. Neither
stage bypasses final application identity constraints or semantic validation.
## Validation And Diagnostics
### Structural Identity Checks
Allow item-local helper imports to complete and consume applicable non-main
source-version headers before checking payload identity for collection assembly.
A helper can supply an identity component; a consumed header cannot supply one
after removal.
Structural controls are likewise unavailable as source identity components.
Check identity against application payload fields, excluding fields acting as
structural directives even when the engine retains them for later processing.
For example, `key: extends` with an item containing `extends: null` does not
supply a null identity: that field cancels inheritance and is consumed. Report
the ordinary missing-identity-component error at the collection/item path using
available origins, without exposing values. Do not defer this failure until
directive consumption or final application validation.
This rule follows a field's structural role; it does not introduce a broader
restriction on unrelated literal field names. Final application identity checks
continue to examine the effective typed payload under their existing rules.
Validate each identified contribution before combination and check uniqueness in
the resulting identified collection, including inheritance operations. Upsert
matches across precedence are replacements; duplicates within a contribution
remain errors. Later inheritance, typed defaults, or callbacks cannot repair an
identity missing at an earlier assembly boundary.
YAML-only identities are enforced during structural composition, precedence
assembly, and identified inheritance operations. They have no final-result
guarantee after typed defaulting, CLI overrides, or application resolution. Those
stages can produce duplicate identities or otherwise violate a YAML-only identity.
Applications needing a final guarantee register the constraint through the
application-owned interface.
Adding record inheritance does not expand this guarantee: a YAML declaration
can enable parent lookup without imposing final validation on values later
produced by defaults, CLI overrides, or application resolution.
### Final Application Constraints
On successful loading, final effective collections covered by application
registrations satisfy their declared identity constraints. Retain assembly-time
checks and validate final effective sequences before publication, after defaults,
inheritance, overrides, application resolution, and secret lookup.
This covers both exact and dynamic application registrations. Evaluate dynamic
selectors against the final effective configuration, with independent validation
for each concrete matched sequence as described under
[Matching Across The Loading Lifecycle](#matching-across-the-loading-lifecycle).
ConfigKit performs these identity checks; consumers register the constraint
without needing to repeat duplicate detection in `Definition.Validate`.
Application-owned semantic validation continues to cover domain-specific rules.
This guarantee covers actual sequences, not path existence or allowed shape.
It does not require every intermediate callback state to satisfy the constraint
and does not cover caller mutations after loading returns. Final checks examine
effective typed values; assembly checks validate authored contributions. A final
scalar alone does not prove that a source explicitly supplied that field.
Use the existing canonical final-value representation and shared publication
path as the foundation. The guarantee requires neither a new mutation-history
system nor exact causal reconstruction for records supplied by defaults or
callbacks.
### Diagnostics And Inspection
Identity diagnostics identify collection and item paths using available origin
information without exposing identity values. Conflicting declarations identify
the collection destination and declaration locations.
For dynamic registrations, use concrete matched collection and item paths for
value diagnostics, with available origins and existing sensitivity rules.
Definition-time overlap errors identify the conflicting registrations; they do
not require or invent a concrete destination from a loaded document.
Correctness includes final values, projected item paths, source destinations,
provenance, history, sensitivity, and passive resource contexts. Appending can
change final indexes; replacing changes which contributions survive. Preserve
the existing loading and inspection contracts through both operations, including
policy ownership and inheritance transfer.
## Work Needed From The Baseline
The baseline needs the following connected capabilities to reach the target
state. Execution order and test sequencing belong to the
[implementation plan](implementation.md):
- **Declarations and public API:** extend application registration to the settled
identity model with one public registration type and definition field, an
exact-path convenience constructor, and an explicit-selector constructor.
Preserve literal `Path` semantics. Parse and consume `configkit.sequences` across
supported source forms and selected overlays; preserve placement-relative and
item-local scopes. Keep sequence declarations independent of filesystem import
enablement.
- **Identity and policy representation:** implement field-set comparison, typed
scalar identity tuples with exact numeric equality, declaration agreement,
pending declarations, explicit-mode persistence, replacement lifetime, and
record-owned policy transfer. Choose bookkeeping proportional to these semantics.
Handle descendant collection removal through ancestor replacement, preserving
independently declared modes and application constraints while discarding
removed collection identity and record-owned policies.
- **Registration matching:** support literal and arbitrary-mapping-key segments,
detect incompatible selector overlap during definition validation, and resolve
applicable constraints at concrete placed destinations. Reuse the identity
rules and validator for exact and dynamic registrations. Keep matching separate
from source discovery and from YAML declaration ownership.
- **Assembly handoffs:** make accumulated lower policy available before higher-tier
strict composition. Separate source acquisition from policy-aware composition
or otherwise revise assembly sequencing; current main-before-underlay composition
is not a design constraint. Preserve distinctions among acquisition order,
dependency-ready scheduling within tiers, and precedence order. Account for
source records, callbacks, main-version checks, and failure diagnostics. Retain
version ownership and validation semantics; higher replacement cannot hide
lower-tier structural failures.
- **Combination and inheritance:** implement the three operations with strict peer
handling, whole-record replacement, presence rules, stable ordering, and the
destination policy rules for inheritance. Extend sibling-scope discovery and
parent lookup to identified sequences, validate mapping-form references, and
preserve child identities while copying parent payloads. Reuse the shared
dependency-ready scheduler and destination-local nested resolution. Preserve
provenance, history, sensitivity, paths, and resource contexts as records survive,
move, or disappear.
- **Lifecycle and validation:** move CLI application after inheritance and typed
defaults/decoding, preserve explicit-presence and source metadata for application
resolution, reject CLI structural directives, and add final application identity
validation through the shared publication path. Keep YAML-only enforcement
within structural operations. Match dynamic registrations during assembly and
inheritance, and against the final effective configuration rather than a saved
list of source-backed destinations.
Exclude structural controls from source identity checks even while retaining
those controls for later inheritance processing.
- **Documentation and verification:** exercise ordinary and layered loading,
imported and inline collections, item-local scopes, version-header consumption,
selected overlays, inheritance, typed defaults, CLI, callbacks, and inspection.
Cover single and compound parent references, scalar equality, forward references
and chains, missing parents and cycles, parent replacement/removal, child identity
preservation, nested destination policy, cancellation, and stable output order.
Verify helper-composed references, overlapping reference fields, missing or extra
fields, and replacement of references together with their containing records.
Verify independent namespace uniqueness, root and namespaced registrations,
literal punctuation keys, equivalent and incompatible overlaps, and conflicts
detected even without a matching input destination. Cover namespaces introduced
by imports, placement, higher tiers, inheritance, defaults, CLI, and callbacks;
preserve ordinary strict-peer and upsert behavior. Check YAML disagreement under
replace, harmless absent or empty collections, schema-owned shape requirements,
unrelated sequences remaining unselected, and concrete-path diagnostics.
Verify both constructors feed the same registration model, including mixed
exact and dynamic registrations, compound identities, and definition-time
rejection of empty identity field sets.
Cover ancestor replacement followed by collection reintroduction, including a
surviving append mode with and without renewed identity, explicit replacement,
and application-registered identity. Verify that `extends: null` cannot supply
an identity component and that structural-control exclusion uses ordinary
value-free missing-identity diagnostics.
Update the public contract and consumer examples when behavior lands.
The collection-policy and CLI-ordering decisions are accepted in
[ADR 0017](../adr/0017-collection-sequence-policy.md) for collection policy and
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) for CLI ordering.
Their scoped supersession is recorded in the affected earlier ADRs, and the
[architecture policy](../policy/architecture.md) reflects the accepted decisions.
These govern the target architecture; the configuration contract continues to
describe implemented behavior until the feature lands. This roadmap does not
select migration machinery.
ADR 0017 now includes inheritance within identified sequences;
[ADR 0009](../adr/0009-sibling-inheritance.md) records that scope extension in its
status while preserving its decision content. The architecture policy is aligned.
Dynamic selectors extend the destinations covered by application registration;
they retain the accepted application authority and final-validation boundary.
No open feature decisions remain. The implementation plan translates this end
state into reviewable stages without adding guarantees, restrictions, or
combination semantics.

View File

@@ -1,151 +0,0 @@
package configkit
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode"
)
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
func TestLocalDocumentationLinks(t *testing.T) {
repository, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
var failures []string
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
target := strings.Trim(match[1], "<>")
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
continue
}
filePart, fragment, _ := strings.Cut(target, "#")
decoded, decodeErr := url.PathUnescape(filePart)
if decodeErr != nil {
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
continue
}
linked := path
if decoded != "" {
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
}
_, statErr := os.Stat(linked)
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
continue // Recorded sibling-source links are optional in isolated clones.
}
if statErr != nil {
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
continue
}
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(failures) != 0 {
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
}
}
func optionalSiblingSource(repository, linked string) bool {
relative, err := filepath.Rel(filepath.Dir(repository), linked)
if err != nil || relative == "." || filepath.IsAbs(relative) {
return false
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) == 0 {
return false
}
switch strings.ToLower(parts[0]) {
case "narratio", "notarius", "weatherreporter", "seriatim":
return true
default:
return false
}
}
func markdownHasFragment(path, wanted string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
wanted, err = url.PathUnescape(wanted)
if err != nil {
return false
}
seen := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") {
continue
}
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
base := markdownFragment(heading)
count := seen[base]
seen[base]++
fragment := base
if count > 0 {
fragment = fmt.Sprintf("%s-%d", base, count)
}
if fragment == wanted {
return true
}
}
return false
}
func markdownFragment(heading string) string {
var result strings.Builder
previousSpace := false
for _, character := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
result.WriteRune(character)
previousSpace = false
case unicode.IsSpace(character):
if result.Len() > 0 && !previousSpace {
result.WriteByte('-')
previousSpace = true
}
}
}
return strings.TrimRight(result.String(), "-")
}
func relativePath(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(relative)
}

View File

@@ -6,7 +6,7 @@ import (
"os"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
func ExampleLoad() {
@@ -26,7 +26,7 @@ func ExampleLoad_sequenceDeclaration() {
type Config struct {
Items []Item `yaml:"items"`
}
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`config:
sequences:
- destination: [items]
key: id
@@ -186,7 +186,8 @@ func ExampleOverride() {
},
Overrides: overrides,
})
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
source, _ := result.Source("source-1")
fmt.Println(result.Config.Workers, source.Label, err)
// Output: 0 --workers <nil>
}

View File

@@ -0,0 +1,26 @@
package consumers
import (
"fmt"
"testing/fstest"
configkit "go.maximumdirect.net/configkit"
)
func Example_documentRootConfig() {
type app struct {
Config struct {
Timeout string `yaml:"timeout"`
} `yaml:"config"`
}
files := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": {Data: []byte("config: {imports: [{path: settings.yml, destination: [config]}]}\n")},
"settings.yml": {Data: []byte("timeout: 10s\n")},
})
result, err := configkit.Load[app](files.File("main.yml"), configkit.Options[app]{Imports: true})
if err != nil {
panic(err)
}
fmt.Println(result.Config.Config.Timeout)
// Output: 10s
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
// SequenceCatalogConfig is a source-backed prompt catalog with an application
@@ -31,28 +31,39 @@ type SequenceCatalogEntry struct {
Credential configkit.Secret `yaml:"credential"`
}
// LoadSequenceCatalog assembles imported main fragments over a file-backed
// underlay, applies one selected collection overlay, then resolves and validates
// the resulting application configuration.
// LoadSequenceCatalog searches its caller-selected main path before a custom
// fallback directory, assembles imported fragments over a file-backed underlay,
// applies one selected collection overlay, then resolves and validates once.
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
root := filepath.Dir(mainPath)
absoluteMain, err := filepath.Abs(mainPath)
if err != nil {
return nil, err
}
root := filepath.Dir(absoluteMain)
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
mainSearch := configkit.SearchConfig("", configkit.Searches(
configkit.SearchPaths(absoluteMain),
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
}),
), configkit.SearchOptions{Required: true})
definition := configkit.Definition[SequenceCatalogConfig]{
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,
resolution.RecordNormalization(configkit.Keys("resolved_label"),
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
return nil
},
Validate: validateSequenceCatalog,
}
return configkit.LoadLayered(configkit.Input{
Source: filesystem.File(filepath.Base(mainPath)),
Imports: true,
Source: mainSearch,
Imports: true,
Filesystem: &configkit.FilesystemContext{Root: root},
}, configkit.LayeredOptions[SequenceCatalogConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: filesystem.File("underlay.yml"),

View File

@@ -8,8 +8,8 @@ import (
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
configkit "go.maximumdirect.net/configkit"
"go.maximumdirect.net/configkit/examples/consumers"
)
func Example_sequenceCatalog() {
@@ -171,7 +171,7 @@ collections:
func Example_yamlDeclaredIdentityOnly() {
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
configkit:
config:
sequences:
- destination: [prompts]
key: id
@@ -193,7 +193,7 @@ prompts:
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

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

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: metadata.yml
destination: []

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: ../shared.yml
enabled: true

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: artifacts.yml
poll_interval: 1m

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: declarations/path.yml
sensitive: PATH_REFERENCE_SECRET_CANARY

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: metadata.yml
- path: prompts/brief.yml

View File

@@ -1,4 +1,4 @@
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

@@ -1,4 +1,4 @@
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: helpers/curated-id.yml
name: Curated summary

View File

@@ -1,4 +1,4 @@
configkit:
config:
imports:
- path: prompts/required.yml
required: true

View File

@@ -1,5 +1,5 @@
run_label: source-default
configkit:
config:
imports:
- dir: fragments
pattern: '*.yml'

View File

@@ -1,4 +1,4 @@
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

@@ -1,4 +1,4 @@
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

@@ -1,4 +1,4 @@
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

@@ -1,4 +1,4 @@
configkit:
config:
sequences:
- destination: [prompts]
key: id

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
package configkit_test
import (
"strings"
"testing"
"testing/fstest"
configkit "go.maximumdirect.net/configkit"
)
func TestDocumentRootControlsAndNestedApplicationConfig(t *testing.T) {
type settings struct {
Imports string `yaml:"imports"`
}
type app struct {
Service struct {
Config settings `yaml:"config"`
} `yaml:"service"`
}
result, err := configkit.Load[app](configkit.Bytes("main", []byte("config: {sequences: []}\nservice: {config: {imports: ordinary}}\n")))
if err != nil || result.Config.Service.Config.Imports != "ordinary" {
t.Fatalf("result = %#v, error = %v", result, err)
}
}
func TestMountedConfigDataKeepsControlEvidenceAndHiding(t *testing.T) {
type settings struct {
Imports string `yaml:"imports"`
Token string `yaml:"token" configkit:"secret"`
}
type app struct {
Config settings `yaml:"config"`
}
fs := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": {Data: []byte("config: {imports: [{path: missing.yml}, {path: data.yml, destination: [config]}]}\n")},
"data.yml": {Data: []byte("imports: ordinary\ntoken: disposable-canary\n")},
})
result, err := configkit.Load[app](fs.File("main.yml"), configkit.Options[app]{Imports: true})
if err != nil {
t.Fatal(err)
}
if result.Config.Config.Imports != "ordinary" {
t.Fatalf("config = %#v", result.Config)
}
sources := result.Sources()
if len(sources) != 2 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Path.String() != "/config/imports/0" {
t.Fatalf("sources = %#v", sources)
}
if _, ok := result.Metadata().Lookup(configkit.Keys("config", "imports")); !ok {
t.Fatal("missing application value")
}
if _, ok := result.Explain(configkit.Keys("config", "imports")); !ok {
t.Fatal("missing explanation")
}
var output strings.Builder
if err := result.WriteEffective(&output); err != nil || strings.Contains(output.String(), "disposable-canary") || !strings.Contains(output.String(), "ordinary") {
t.Fatalf("effective output = %q, %v", output.String(), err)
}
}
func TestOldControlSpellingsAreOrdinaryData(t *testing.T) {
body := "configkit: {imports: [{path: nonexistent.yml}]}\n_configkit: ordinary\n"
result, err := configkit.Load[map[string]any](configkit.Bytes("old", []byte(body)))
if err != nil || result.Config["configkit"] == nil || result.Config["_configkit"] != "ordinary" {
t.Fatalf("result = %#v, error = %v", result, err)
}
if _, err := configkit.Load[struct{}](configkit.Bytes("strict", []byte(body))); err == nil {
t.Fatal("strict schema accepted unknown data")
}
}
func TestDefaultsOverridesAndResolutionAtConfigRoot(t *testing.T) {
type settings struct {
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
}
type app struct {
Config settings `yaml:"config"`
}
entryExplicit := false
entryCountExplicit := false
result, err := configkit.Load[app](configkit.NoSource(), configkit.Options[app]{
Definition: configkit.Definition[app]{
Defaults: func() app { return app{Config: settings{Enabled: true, Count: 2}} },
Resolve: func(value *app, r *configkit.Resolution) error {
info, ok := r.EntryInfo(configkit.Keys("config", "enabled"))
entryExplicit = ok && info.Explicit && info.Origin.Layer == configkit.LayerCLI
entryCountExplicit = r.IsExplicit(configkit.Keys("config", "count"))
value.Config.Count = 7
r.RecordNormalization(configkit.Keys("config", "count"), "normalize count")
return nil
},
},
Overrides: []configkit.Override{{Path: configkit.Keys("config", "enabled"), Value: false, Flag: "--enabled"}},
})
if err != nil || result == nil || !entryExplicit || entryCountExplicit || result.Config.Config.Enabled || result.Config.Config.Count != 7 {
t.Fatalf("result = %#v, entry explicit = %t, error = %v", result, entryExplicit, err)
}
if info, ok := result.Metadata().Lookup(configkit.Keys("config", "enabled")); !ok || !info.Explicit || info.Origin.Layer != configkit.LayerCLI {
t.Fatalf("final enabled info = %#v, %t", info, ok)
}
if info, ok := result.Metadata().Lookup(configkit.Keys("config", "count")); !ok || info.Origin.Layer != configkit.LayerResolution {
t.Fatalf("final count info = %#v, %t", info, ok)
}
if _, ok := result.Explain(configkit.Keys("config", "count")); !ok {
t.Fatal("missing resolution explanation")
}
}

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
@@ -109,7 +109,7 @@ func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T)
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("extends")},
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
} {
t.Run(test.name, func(t *testing.T) {
@@ -129,7 +129,7 @@ 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 {
if err := os.WriteFile(mainPath, []byte("config: {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 {
@@ -169,7 +169,7 @@ func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
return filename
}
mainPath := write("main.yml", `configkit:
mainPath := write("main.yml", `config:
imports:
- path: metadata.yml
destination: [metadata]
@@ -184,7 +184,7 @@ 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:
write("entries/main.yml", `config:
imports:
- path: nested/details.yml
destination: [details]
@@ -352,7 +352,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
callbacks.resolve++
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
input, ok := resolution.InputInfo(resourcePath)
input, ok := resolution.EntryInfo(resourcePath)
if !ok || len(input.Origin.Locations) != 1 {
return fmt.Errorf("mounted resource input has no single source origin")
}
@@ -370,11 +370,11 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
}
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
entry["resource_text"] = string(data)
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
resolution.RecordNormalization(configkit.Keys("catalog", "entries", "item", "resource_text"), "read source-relative summary", resourcePath)
return nil
},
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
Sensitive: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
},
})
if err != nil {
@@ -405,7 +405,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
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 {
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerMain {
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
}
if history := result.Metadata().History(titlePath); len(history) < 2 {
@@ -505,7 +505,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
}
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")
mainPath := write("parse/main.yml", "config:\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"),
@@ -525,8 +525,8 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
})
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")
mainPath := write("directive/main.yml", "config:\n imports:\n - path: parent.yml\n destination: [tenant]\n")
parentPath := write("directive/parent.yml", "config:\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"),
})
@@ -535,7 +535,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
t.Fatalf("load error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
wantPath := configkit.Keys("config", "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)
}
@@ -546,7 +546,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
})
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")
mainPath := write("compose/main.yml", "config:\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{
@@ -567,7 +567,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
})
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")
mainPath := write("version/main.yml", "config:\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"),
@@ -599,7 +599,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
} `yaml:"tenant"`
} `yaml:"mount"`
}
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
mainPath := write("typed/main.yml", "config:\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"),

View File

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

View File

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

316
facade_discovery_test.go Normal file
View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
//go:embed testdata/filesystem
@@ -57,7 +57,7 @@ func TestFacadeFilesystemSourceImportsOverlayAndReuse(t *testing.T) {
func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
first := configkit.NewFilesystem("same", fstest.MapFS{
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\n")},
"root/config/main.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: ../shared.yml}]}\n")},
"root/shared.yml": &fstest.MapFile{Data: []byte("items: {value: first}\n")},
"root/overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {overlay: first}\n")},
})
@@ -82,8 +82,8 @@ func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
source configkit.Source
kind configkit.SourceKind
}{
{"bytes", configkit.Bytes("memory", []byte("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindReader},
{"bytes", configkit.Bytes("memory", []byte("config: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader("config: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindReader},
} {
t.Run(input.name, func(t *testing.T) {
result, err := configkit.Load(input.source, configkit.Options[filesystemFacadeConfig]{Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first}})

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit"
"go.maximumdirect.net/configkit"
)
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
@@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
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")
resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
return nil
}
}
@@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
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")
resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
return nil
}
}
@@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
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")
resolution.RecordNormalization(configkit.Keys("collections"), "create namespaced records")
return nil
}
}

View File

@@ -0,0 +1,120 @@
package configkit_test
import (
"reflect"
"testing"
configkit "go.maximumdirect.net/configkit"
)
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte(
"retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n",
)), configkit.Options[map[string]any]{Overrides: []configkit.Override{
{Path: configkit.Keys("retired"), Value: nil, Flag: "--retired"},
{Path: configkit.Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
}})
if err != nil {
t.Fatalf("Load: %v", err)
}
metadata := loaded.Metadata()
paths := metadata.HistoricalPaths()
got := make([]string, len(paths))
for index, path := range paths {
got[index] = path.String()
if len(metadata.History(path)) == 0 {
t.Errorf("enumerated path %s has no history", path)
}
}
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("historical paths = %v, want sorted unique pointers %v", got, want)
}
if _, ok := metadata.Lookup(paths[4]); ok {
t.Fatal("removed descendant remains effective")
}
if _, ok := metadata.Lookup(paths[6]); !ok {
t.Fatal("enumerated numeric path did not follow the effective sequence shape")
}
if len(metadata.History(paths[6])) == 0 {
t.Fatal("enumerated numeric path lost history")
}
assertPublicShapeHistory(t, metadata, configkit.LayerCLI)
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
t.Fatal("mapping-key path resolved against the effective sequence")
}
if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
t.Fatal("zero metadata contains historical paths")
}
if (configkit.Metadata{}).History(configkit.Keys("shape")) != nil {
t.Fatal("zero metadata contains history")
}
paths[1] = configkit.Keys("mutated")
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
t.Fatalf("returned path slice mutated retained metadata: %v", pathStrings(again))
}
}
func TestPublicHistoryUsesResolutionShapeAttribution(t *testing.T) {
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte("shape: {'0': base}\n")), configkit.Options[map[string]any]{
Definition: configkit.Definition[map[string]any]{
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
(*configuration)["shape"] = []any{"replacement"}
resolution.RecordNormalization(configkit.Keys("shape"), "replace numeric mapping key")
return nil
},
},
})
if err != nil {
t.Fatalf("Load: %v", err)
}
assertPublicShapeHistory(t, loaded.Metadata(), configkit.LayerResolution)
}
func assertPublicShapeHistory(t *testing.T, metadata configkit.Metadata, operationLayer configkit.Layer) {
t.Helper()
keyPath := configkit.Keys("shape", "0")
indexPath := configkit.Keys("shape").Index(0)
keyHistory := metadata.History(keyPath)
indexHistory := metadata.History(indexPath)
if len(keyHistory) == 0 || len(indexHistory) == 0 {
t.Fatalf("explicit shape histories = key %#v, index %#v", keyHistory, indexHistory)
}
var keyDefinition, keyReplacement, indexReplacement bool
for _, change := range keyHistory {
keyDefinition = keyDefinition || change.Origin.Layer == configkit.LayerMain
keyReplacement = keyReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
}
for _, change := range indexHistory {
if change.Origin.Layer == configkit.LayerMain {
t.Fatalf("introduced index inherited mapping-key history: %#v", indexHistory)
}
indexReplacement = indexReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
for _, previous := range change.Previous {
if previous.Layer == configkit.LayerMain {
t.Fatalf("introduced index inherited mapping-key previous origin: %#v", change)
}
}
}
if !keyDefinition || !keyReplacement || !indexReplacement {
t.Fatalf("shape histories missed applicable definitions or replacement: key %#v, index %#v", keyHistory, indexHistory)
}
parsed, err := configkit.ParsePath("/shape/0")
if err != nil {
t.Fatal(err)
}
if parsedHistory := metadata.History(parsed); len(parsedHistory) == 0 {
t.Fatal("parsed numeric query lost contextual history")
}
}
func pathStrings(paths []configkit.Path) []string {
result := make([]string, len(paths))
for index, path := range paths {
result[index] = path.String()
}
return result
}

View File

@@ -8,13 +8,13 @@ import (
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/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/item.yml": "config:\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",
@@ -50,7 +50,7 @@ func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
{"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]{
result, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
})
if err != nil {
@@ -82,14 +82,14 @@ func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
// 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]{
_, err := configkit.Load(configkit.Bytes("main", []byte("config:\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]{
_, err = configkit.Load(configkit.Bytes("main", []byte("config:\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 {

View File

@@ -6,8 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
@@ -21,7 +19,7 @@ func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
{"-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")
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfig:\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 {
@@ -33,7 +31,7 @@ func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
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" {
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/config/imports/0/depth" {
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
}
})
@@ -49,12 +47,12 @@ func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
IgnoredDeep string `yaml:"ignored_deep"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
mainPath := writeFacadeImportFile(t, directory, "main.yml", `config:
imports:
- path: parent/parent.yml
main: main
`)
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
writeFacadeImportFile(t, directory, "parent/parent.yml", `config:
imports:
- dir: generated
- dir: explicit
@@ -66,7 +64,7 @@ main: main
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:
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `config:
imports:
- dir: underlay-files
`)
@@ -95,25 +93,32 @@ main: main
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
type config struct {
Value string `yaml:"value"`
Value string `yaml:"value"`
Hidden string `yaml:"hidden"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
mainPath := writeFacadeImportFile(t, directory, "main.yml", `config:
imports:
- dir: snippets
`)
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
writeFacadeImportFile(t, directory, "snippets/.hidden.cfg", "hidden: imported\n")
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
if err != nil || unrestricted.Config.Value != "imported" {
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
}
includeHidden := false
visibleOnly, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{IncludeHidden: &includeHidden}})
if err != nil || visibleOnly.Config.Value != "imported" || visibleOnly.Config.Hidden != "" {
t.Fatalf("hidden-file default = %#v, %v", visibleOnly, err)
}
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
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 {
if len(emptySources) != 1 || len(emptySources[0].EmptyImportSelections) != 1 || emptySources[0].EmptyImportSelections[0].Path.String() != "/config/imports/0" || emptySources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("empty-pattern evidence = %#v", emptySources)
}
required := true
@@ -122,23 +127,64 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("required empty selection error = %T %v", err, err)
}
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
}
}
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
type config struct {
Version string `yaml:"version"`
Imported string `yaml:"imported"`
}
for _, layered := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
config:
imports:
- dir: recursive-files
- dir: parts
`)
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
recursive := true
patterns := []string{"*.cfg"}
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
recursive = false
patterns[0] = "["
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
if err != nil || loadedEngine.Config.Value != "selected" {
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
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)
}
})
}
}
@@ -183,7 +229,7 @@ func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution
Value string `yaml:"value"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
mainPath := writeFacadeImportFile(t, directory, "main.yml", `config:
imports:
- dir: absent
value: loaded
@@ -191,33 +237,33 @@ 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 {
if !ok || len(first.EmptyImportSelections) != 1 {
t.Fatalf("resolution source = %#v, %v", first, ok)
}
first.ImportSelections[0].Path = Keys("callback mutation")
first.EmptyImportSelections[0].Path = Keys("callback mutation")
second, ok := resolution.Source("source-1")
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
if !ok || second.EmptyImportSelections[0].Path.String() != "/config/imports/0" {
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
}
callbackPath = second.ImportSelections[0].Path.String()
callbackPath = second.EmptyImportSelections[0].Path.String()
return nil
}}})
if err != nil {
t.Fatal(err)
}
if callbackPath != "/configkit/imports/0" {
if callbackPath != "/config/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 {
if len(sources) != 1 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Location.SourceID != sources[0].ID || sources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("result source evidence = %#v", sources)
}
sources[0].ImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/config/imports/0" {
t.Fatalf("result snapshot changed after mutation: %q", got)
}
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `config:
imports:
- dir: absent
- path: required-missing.yml
@@ -229,11 +275,11 @@ value: loaded
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" {
if len(errorSources) != 1 || len(errorSources[0].EmptyImportSelections) != 1 || errorSources[0].EmptyImportSelections[0].Path.String() != "/config/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" {
errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/config/imports/0" {
t.Fatalf("error snapshot changed after mutation: %q", got)
}
}

View File

@@ -12,7 +12,7 @@ import (
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
filesystem := NewFilesystem("bundle", fstest.MapFS{
"bundle/config/main.yml": {Data: []byte(`configkit:
"bundle/config/main.yml": {Data: []byte(`config:
imports:
- dir: records
pattern: '*-item.yml'
@@ -23,7 +23,7 @@ services:
- id: authored
version: authored
`)},
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
"bundle/config/records/a-item.yml": {Data: []byte(`config:
imports:
- path: helper.yml
version: ` + canary + `
@@ -42,7 +42,7 @@ entries:
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 {
Sensitive: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version"
},
},
@@ -67,6 +67,14 @@ entries:
}
sources := result.Sources()
if len(sources) < 3 || sources[0].Layer != LayerMain || string(sources[0].Layer) != "main" {
t.Fatalf("main source attribution = %#v", sources)
}
for _, source := range sources[1:] {
if source.ImportedFrom != nil && (source.Layer != LayerMain || string(source.Layer) != "main") {
t.Errorf("imported main source attribution = %#v", source)
}
}
filesByID := make(map[string]string, len(sources))
for _, source := range sources {
filesByID[source.ID] = source.Path
@@ -77,8 +85,8 @@ entries:
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)
if itemSource.VersionEvidence == nil || !itemSource.VersionEvidence.Version.Present || !itemSource.VersionEvidence.Redacted || itemSource.VersionEvidence.Version.Text != "" {
t.Fatalf("consumed header inspection = %#v", itemSource.VersionEvidence)
}
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" {
@@ -107,7 +115,7 @@ entries:
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 {
Sensitive: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version"
},
},
@@ -129,7 +137,7 @@ entries:
break
}
}
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
if !foundFailedItem || failedItemSource.VersionEvidence == nil || failedItemSource.VersionEvidence.Version.Text != "" || !failedItemSource.VersionEvidence.Redacted {
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
}
}

View File

@@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
@@ -29,7 +29,7 @@ func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
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)
contents := fmt.Sprintf("config:\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)
}
@@ -65,7 +65,7 @@ func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
"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})
result, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
if err != nil {
t.Fatal(err)
}
@@ -81,7 +81,7 @@ func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
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})
_, err := configkit.Load(configkit.Bytes("main", []byte("config:\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)
@@ -107,7 +107,7 @@ func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
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})
result, err := configkit.Load(configkit.Bytes("main", []byte("config:\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)
}

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
package configkit_test
import (
"fmt"
"testing"
"testing/fstest"
configkit "go.maximumdirect.net/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 := "config: {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("config: {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\nconfig: {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

@@ -4,7 +4,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
@@ -13,18 +13,18 @@ func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
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: "helper", item: "id: item\nconfig: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
{name: "nested", item: "id: parent\nconfig: {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},
{name: "main-item", item: "id: parent\nconfig: {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")},
"main.yml": {Data: []byte("version: 1\nconfig: {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")},
"underlay.yml": {Data: []byte("config: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
}
filesystem := configkit.NewFilesystem("items", files)
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{

View File

@@ -10,7 +10,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
type layeredFacadeConfig struct {
@@ -28,7 +28,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
t.Fatal(err)
}
lower := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")},
"main.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")},
"defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")},
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")},
})
@@ -59,8 +59,8 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
return nil
},
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
Sensitive: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
},
})
if err != nil {
@@ -80,7 +80,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
if len(sources) != 8 {
t.Fatalf("sources = %#v", sources)
}
wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
wantLayers := []configkit.Layer{configkit.LayerMain, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
for index, source := range sources {

View File

@@ -7,7 +7,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
type mappingFacadeConfig struct {
@@ -36,7 +36,7 @@ func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
if !reflect.DeepEqual(result.Config, want) {
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" {
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerMain || sources[0].Path != "" {
t.Fatalf("mapping sources = %#v", sources)
}
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
@@ -80,8 +80,8 @@ func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
"root/shared.yml": &fstest.MapFile{Data: []byte("name: imported\n")},
})
source := configkit.Mapping("programmatic", map[string]any{
"configkit": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
"enabled": true,
"config": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
"enabled": true,
})
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
@@ -89,7 +89,7 @@ func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
t.Fatalf("mapping import = %#v, %v", result, err)
}
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase {
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerMain {
t.Fatalf("mapping import sources = %#v", sources)
}
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)

View File

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

View File

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

View File

@@ -0,0 +1,223 @@
package configkit_test
import (
"errors"
"strings"
"testing"
"go.maximumdirect.net/configkit"
)
type resolutionSequenceItem struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type resolutionSequenceConfig struct {
Groups map[string][]resolutionSequenceItem `yaml:"groups"`
}
func TestPublicResolutionOrdinaryListField(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
definition := configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error {
configkit.Normalize(r, configkit.Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize")
return nil
}}
result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: old}]\n")), configkit.Options[config]{Definition: definition})
if err != nil || result.Config.Items[0].Value != "changed" {
t.Fatalf("result = %#v, %v", result, err)
}
}
func TestPublicRegisteredResolutionHistoryAndOwnership(t *testing.T) {
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("groups"), configkit.SequenceAnyMappingKey()}
path := configkit.Keys("groups", "active")
entryFixed := false
definition := configkit.Definition[resolutionSequenceConfig]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityMatching(selector, "id")},
Resolve: func(value *resolutionSequenceConfig, r *configkit.Resolution) error {
entryFixed = r.IsExplicit(path.Index(0).Key("value")) && !r.IsExplicit(path.Index(1).Key("value"))
items := value.Groups["active"]
items[0], items[1] = items[1], items[0]
items[1].Value = "normalized"
value.Groups["active"] = items
r.RecordNormalization(path, "reorder")
r.RecordNormalization(path.Index(1).Key("value"), "normalize")
return nil
},
}
source := configkit.Bytes("records", []byte("groups:\n active:\n - id: a\n value: original\n - id: b\n"))
load := func() *configkit.Result[resolutionSequenceConfig] {
result, err := configkit.Load(source, configkit.Options[resolutionSequenceConfig]{Definition: definition})
if err != nil {
t.Fatal(err)
}
return result
}
result := load()
if !entryFixed || result.Config.Groups["active"][1].Value != "normalized" {
t.Fatalf("result = %#v", result.Config)
}
info, ok := result.Metadata().Lookup(path.Index(1).Key("value"))
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerResolution {
t.Fatalf("metadata = %#v, %t", info, ok)
}
explanation, ok := result.Explain(path.Index(1).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
found := false
for _, change := range explanation.History {
if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "normalized\n" {
found = true
}
}
if !found {
t.Fatalf("history = %#v", explanation.History)
}
other := load()
result.Config.Groups["active"][1].Value = "mutated"
if len(explanation.History) != 0 {
explanation.History[0].After.YAML = "mutated"
}
again, _ := result.Explain(path.Index(1).Key("value"))
if strings.Contains(again.Effective.YAML, "mutated") || other.Config.Groups["active"][1].Value != "normalized" {
t.Fatal("inspection or independent load changed")
}
plain, err := configkit.Load[resolutionSequenceConfig](source)
if err != nil {
t.Fatal(err)
}
differences, err := configkit.Diff(plain, result)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/groups/active" {
t.Fatalf("diff = %#v, %v", differences, err)
}
layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[resolutionSequenceConfig]{Definition: definition})
if err != nil || layered.Config.Groups["active"][1].Value != other.Config.Groups["active"][1].Value {
t.Fatalf("layered = %#v, %v", layered, err)
}
}
func TestPublicRegisteredMoveRequiresContainerRecord(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
_, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a}, {id: b}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Resolve: func(value *config, r *configkit.Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(configkit.Keys("items").Index(1).Key("id"), "record field")
return nil
},
}})
var failure *configkit.LoadError
if !errors.As(err, &failure) {
t.Fatalf("error = %v", err)
}
for _, diagnostic := range failure.Diagnostics() {
if diagnostic.Code == configkit.CodeResolveUnrecordedChange && diagnostic.Path.String() == "/items" {
return
}
}
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
func TestPublicRegisteredRemovalKeepsSurvivorHistory(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
definition := configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Resolve: func(value *config, r *configkit.Resolution) error {
value.Items = value.Items[1:]
r.RecordNormalization(configkit.Keys("items"), "remove first")
return nil
},
}
result, err := configkit.Load(configkit.Bytes("items", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), configkit.Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("missing survivor")
}
for _, change := range explanation.History {
if change.Origin.Layer == configkit.LayerMain && change.After.YAML == "report\n" {
t.Fatalf("wrong lineage = %#v", explanation.History)
}
}
container, ok := result.Explain(configkit.Keys("items"))
if !ok {
t.Fatal("missing collection explanation")
}
found := false
for _, change := range container.History {
if change.Origin.Layer == configkit.LayerResolution && strings.Contains(change.Before.YAML, "reports") {
found = true
}
}
if !found {
t.Fatalf("missing removal evidence = %#v", container.History)
}
if _, ok := result.Explain(configkit.Keys("items").Index(1)); !ok {
t.Fatal("missing historical position")
}
}
func TestPublicYAMLIdentityRemainsPositionalDuringResolution(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
result, err := configkit.Load(configkit.Bytes("items", []byte("config: {sequences: [{destination: [items], key: id}]}\nitems: [{id: a, value: first}, {id: b, value: second}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(configkit.Keys("items"), "reorder")
return nil
}}})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
for _, change := range explanation.History {
if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "first\n" && change.After.YAML == "second\n" {
return
}
}
t.Fatalf("positional history = %#v", explanation.History)
}
func TestPublicMovedSensitiveRecordIsHidden(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: FACADE_PRIVATE_CANARY}, {id: b, value: public}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/value" },
Resolve: func(value *config, r *configkit.Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(configkit.Keys("items"), "reorder")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
for _, path := range []configkit.Path{configkit.Keys("items"), configkit.Keys("items").Index(1).Key("value")} {
explanation, ok := result.Explain(path)
if !ok {
t.Fatalf("missing explanation %s", path.String())
}
if strings.Contains(explanation.Effective.YAML, "FACADE_PRIVATE_CANARY") {
t.Fatal("effective leak")
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, "FACADE_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "FACADE_PRIVATE_CANARY") {
t.Fatal("history leak")
}
}
}
}

View File

@@ -12,7 +12,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
type observedResourceFS struct {
@@ -45,7 +45,7 @@ func TestFacadeSourceContextsFollowOriginsWithoutQueryIO(t *testing.T) {
t.Fatal(err)
}
borrowed := &observedResourceFS{files: fstest.MapFS{
"bundle/config/defaults.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nnative: icon.txt\n")},
"bundle/config/defaults.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: ../shared.yml}]}\nnative: icon.txt\n")},
"bundle/shared.yml": &fstest.MapFile{Data: []byte("shared: shared.txt\n")},
"bundle/overlays/test.yml": &fstest.MapFile{Data: []byte("selected: overlay.txt\n")},
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon-v1")},
@@ -224,8 +224,8 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
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/main.yml": &fstest.MapFile{Data: []byte("config:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("config:\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")},

View File

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

View File

@@ -9,7 +9,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
@@ -54,12 +54,12 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
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")},
"main.yml": {Data: []byte("records: [{id: authored}]\nconfig: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
"one.yml": {Data: []byte("id: one\nconfig: {imports: [{path: helper-one.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n")},
"two.yml": {Data: []byte("id: two\nconfig: {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.yml": {Data: []byte("id: child\nconfig: {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]{
@@ -107,7 +107,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
{"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"
mainYAML := "config:\n sequences:\n - destination: [records]\n key: id\n mode: replace\n"
if len(test.upper) == 0 {
mainYAML += "records: []\n"
} else {
@@ -125,7 +125,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
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 {
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerMain || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
t.Fatalf("wrong removal transition/origin: %#v", last)
}
})
@@ -135,12 +135,12 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
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")},
"main.yml": {Data: []byte("records: [{id: authored}]\nconfig: {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}]\nconfig: {imports: [{path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
"nested.yml": {Data: []byte("records: [{id: authored}]\nconfig: {imports: [{path: parent.yml, destination: [records], placement: sequence-item}]}\n")},
"parent.yml": {Data: []byte("id: parent\nchildren: [{id: authored-child}]\nconfig: {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")},
"bad.yml": {Data: []byte("id: bad\nconfig: {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")}}
@@ -211,7 +211,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
}
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved {
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationIsFallback {
t.Fatalf("ordinary item source = %#v", sources)
}
@@ -230,7 +230,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
t.Fatalf("layered item placement = %#v", layered.Config)
}
for _, source := range layered.Sources() {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
t.Errorf("layered item source = %#v", source)
}
}
@@ -277,7 +277,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
func TestFacadeItemImportGlobComposesWithAuthoredSequence(t *testing.T) {
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
if err := os.WriteFile(mainPath, []byte(`configkit:
if err := os.WriteFile(mainPath, []byte(`config:
imports:
- glob: records/*.yml
destination: [items]
@@ -334,7 +334,7 @@ items:
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 {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationIsFallback {
t.Errorf("callback source = %#v", source)
}
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
@@ -349,7 +349,7 @@ items:
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
}
}
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationIsFallback {
t.Fatalf("final item sources = %#v", got)
}
}
@@ -358,7 +358,7 @@ 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 {
if err := os.WriteFile(mainPath, []byte("config: {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 {
@@ -372,9 +372,9 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
t.Fatalf("early failure = %v", err)
}
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
sources[1].Destination.String() != "/items" || !sources[1].DestinationIsFallback ||
sources[1].PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("early source snapshots = %#v", sources)
}
@@ -399,13 +399,13 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
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 {
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationIsFallback {
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 {
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationIsFallback {
t.Fatalf("failure source snapshots were aliased: %#v", again)
}
@@ -420,7 +420,7 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
if err != nil {
t.Fatalf("successful item load: %v", err)
}
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationIsFallback || callback.PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("callback source snapshot = %#v", callback)
}
}
@@ -430,10 +430,10 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
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 {
if err := os.WriteFile(mainPath, []byte("config: {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 {
if err := os.WriteFile(childPath, []byte("config: {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 {
@@ -450,13 +450,13 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
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 {
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationIsFallback || 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 {
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || 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 {
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeMapping {
t.Errorf("nested helper fallback = %#v", got)
}
}
@@ -496,7 +496,7 @@ func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
zero := configkit.SourceInfo{}
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationIsFallback {
t.Fatalf("zero source info = %#v", zero)
}
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
@@ -504,12 +504,12 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
t.Fatal(err)
}
sources := loaded.Sources()
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationIsFallback {
t.Fatalf("mapping source evidence = %#v", sources)
}
first := sources[0]
first.Destination = configkit.Keys("caller")
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationIsFallback {
t.Fatalf("source evidence changed after caller mutation: %#v", again)
}
}
@@ -556,7 +556,7 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
underlayPath := filepath.Join(directory, "underlay.yml")
if err := os.WriteFile(mainPath, []byte(`configkit:
if err := os.WriteFile(mainPath, []byte(`config:
sequences:
- destination: [entries]
key: id
@@ -571,7 +571,7 @@ entries:
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(underlayPath, []byte(`configkit:
if err := os.WriteFile(underlayPath, []byte(`config:
imports:
- glob: records/*.yml
destination: [entries]
@@ -598,7 +598,7 @@ entries:
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 {
Sensitive: func(path configkit.Path) bool {
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
},
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
@@ -611,8 +611,8 @@ entries:
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 {
info, found := resolution.EntryInfo(configkit.Keys("entries").Index(0).Key("count"))
if !found || info.Origin.Layer != configkit.LayerMain {
t.Errorf("resolver current-record input info = %#v, %v", info, found)
}
for _, id := range []string{"source-3", "source-4"} {
@@ -646,7 +646,7 @@ entries:
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
}
for _, source := range callbackItems {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
t.Errorf("callback item source = %#v", source)
}
}

View File

@@ -5,7 +5,7 @@ import (
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
configkit "go.maximumdirect.net/configkit"
)
type movedVersionItem struct {
@@ -64,7 +64,7 @@ func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
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() {
if source.Layer == configkit.LayerMain && source.VersionEvidence.Path.String() != path.String() {
t.Fatal("effective movement changed source-local version evidence")
}
}
@@ -131,7 +131,7 @@ func TestUnderlayVersionProtectionUsesMainRecordPosition(t *testing.T) {
want := configkit.CodeUnderlayVersion
if imported {
files := configkit.NewFilesystem("lower", fstest.MapFS{
"main.yml": {Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
"main.yml": {Data: []byte("config: {imports: [{path: fragment.yml}]}\n")},
"fragment.yml": {Data: []byte(payload)},
})
lower.Source, lower.Imports = files.File("main.yml"), true

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