209 Commits

Author SHA1 Message Date
137769f3f1 Document the ConfigKit v0.8.0 release 2026-10-17 04:58:01 +00:00
ef28eabaf8 Retire completed sequence composition plans 2026-10-17 04:56:51 +00:00
008d2ba9c8 Clarify when sequence identities are validated 2026-10-17 04:55:32 +00:00
8e3a0892d4 Project item conflict diagnostics through enclosing scopes 2026-10-17 04:54:01 +00:00
023e428b9c Index sequence assembly and history lookups 2026-10-17 04:52:20 +00:00
527a38daf1 Preserve defaults-only input absence 2026-10-17 04:49:33 +00:00
30bcd8eeb6 Protect the main version inside item scopes 2026-10-17 04:48:25 +00:00
884215c403 Honor parsed sequence indexes in version protections 2026-10-17 04:46:53 +00:00
8a89806e44 Explain descendants removed by sequence replacement 2026-10-17 04:45:49 +00:00
26720b98f0 Preserve item helper history in source order 2026-10-17 04:44:29 +00:00
4633a1b8a2 Project consumed version evidence through sequence appends 2026-10-17 04:42:23 +00:00
87986a12d2 Record sequence composition review findings 2026-10-17 01:50:02 +00:00
0858af1029 Report malformed sequence items at assigned paths 2026-10-16 19:41:48 +00:00
acddce8e2f Document sequence composition for consumers 2026-10-16 19:37:59 +00:00
77c5d9482a Close cross-cutting sequence regression gaps 2026-10-16 19:32:31 +00:00
adef16ccdd Expose sequence placement controls in the public API 2026-10-16 19:23:43 +00:00
c19825620c Project version protections through item placement 2026-10-16 19:11:08 +00:00
190431740a Apply sequence policy across loading tiers 2026-10-16 18:59:52 +00:00
25cf6d32b6 Assemble imported sequence-item scopes 2026-10-16 16:20:14 +00:00
1a04bb3835 Add private sequence-item placement 2026-10-16 15:55:29 +00:00
1cf3b0d78f Retain ordered acquisition scopes 2026-10-16 15:48:36 +00:00
66dced0823 Project history and sensitivity paths 2026-10-16 15:44:44 +00:00
b1b5163cc3 Preserve append history across tiers 2026-10-16 15:36:46 +00:00
bb9bde799a Project sequence origins onto appended indexes 2026-10-16 15:29:27 +00:00
dc7a70811a Compose registered sequences by identity 2026-10-16 15:25:06 +00:00
ff212885c4 Prepare sequence registration preflight 2026-10-16 15:19:32 +00:00
90c2d7e409 Clarify unresolved source destination evidence in implementation plan 2026-10-16 14:49:23 +00:00
60e6e2697f Plan sequence placement and composition 2026-10-16 14:36:46 +00:00
20511773fc Settle sequence placement and composition roadmap decisions 2026-10-16 14:22:59 +00:00
31fb6a9bd1 Extend sequence roadmap with item placement and open decisions 2026-10-16 14:18:36 +00:00
103e10639a Plan identity-based sequence composition 2026-10-16 14:05:43 +00:00
97c5a0390a Retire deferred scope and preserve future performance experiments 2026-10-16 13:53:34 +00:00
8b547765f0 Record selective source version requirements as future work 2026-10-16 13:42:37 +00:00
5215cf5c4f Catalog identified record sequences as future work 2026-10-16 12:07:49 +00:00
e66c37161c Retire completed roadmaps before the next release 2026-10-16 11:36:21 +00:00
e4270c9b14 Allow absent version headers below structural controls 2026-10-15 22:22:56 +00:00
e69ad0f32d Keep complete paths in missing version evidence 2026-10-15 22:20:20 +00:00
0a2246bdbe Check import opt-in before processing source headers 2026-10-15 22:18:57 +00:00
e5a6df7af7 Preserve header sensitivity across colliding path shapes 2026-10-15 22:17:49 +00:00
25cb3c47e7 Record source version review findings 2026-10-15 22:11:44 +00:00
020d72084d Document shared source version checks 2026-10-15 16:23:39 +00:00
358b28c421 Add source version adoption example 2026-10-15 16:19:24 +00:00
b0550af6a3 Audit source version lifecycle agreement 2026-10-15 16:17:21 +00:00
43c994a32a Publish detached sanitized source evidence 2026-10-15 16:12:21 +00:00
1e47ee21cd Propagate source evidence sensitivity through history 2026-10-15 16:00:01 +00:00
fed591183b Classify source version evidence at original paths 2026-10-15 15:55:16 +00:00
1f95cf905a Expose source version modes in load options 2026-10-15 15:48:59 +00:00
15f5b591b9 Check source versions in selected overlays 2026-10-15 15:44:04 +00:00
184f29c7af Check source versions in direct underlays 2026-10-15 15:40:11 +00:00
813d6a38c9 Validate source versions in recursive imports 2026-10-15 15:33:40 +00:00
23ae700194 Integrate source version checks for main inputs 2026-10-15 15:24:53 +00:00
49218a836b Add source-local version extraction 2026-10-15 15:15:05 +00:00
2fcf892324 Separate version interpretation from reporting 2026-10-15 15:04:57 +00:00
8645cab79a Record shared source version policy 2026-10-15 15:02:50 +00:00
70c95bf660 Plan shared source version checks 2026-10-15 14:24:51 +00:00
d9f08ff719 Settle source version edge-case decisions 2026-10-15 14:05:25 +00:00
2c91e41d10 Record source version decisions and remaining edge cases 2026-10-14 20:30:58 +00:00
e3860036f8 Plan shared source version checks 2026-10-14 19:03:25 +00:00
b3fc6a5950 Restore the original input destination decision 2026-10-14 18:05:48 +00:00
a858a066e4 Record relative import destination review findings 2026-10-14 17:52:14 +00:00
10edd95d4d Complete relative import destination documentation audit 2026-10-14 17:45:31 +00:00
08e03f6a38 Demonstrate entry-declared import destinations 2026-10-14 17:41:35 +00:00
a20ba0af27 Verify relative imports through the application lifecycle 2026-10-14 17:39:28 +00:00
6458dbd3aa Preserve import provenance and resource contexts 2026-10-14 17:34:52 +00:00
43e55dfa85 Verify accumulated import resource accounting 2026-10-14 17:29:54 +00:00
430985c629 Verify relative import composition and version ownership 2026-10-14 17:27:49 +00:00
4635ef08d5 Add relative destinations to import declarations 2026-10-14 17:24:57 +00:00
49c0c6d908 Pass effective destinations through import traversal 2026-10-14 17:21:07 +00:00
a8beb3e620 Record relative import destination decision and baseline 2026-10-14 17:17:50 +00:00
934c1022e0 Record PromptKit feature request decisions 2026-10-14 17:09:23 +00:00
7248a8bd95 Plan relative import destination implementation 2026-10-14 16:53:50 +00:00
b2db3233b2 Settle relative import destination syntax 2026-10-14 16:44:11 +00:00
edb6d05ec1 Plan relative import destinations 2026-10-14 16:39:10 +00:00
315f464dfc Correct mounted mapping diagnostic paths 2026-10-14 15:49:51 +00:00
a029a2a754 Record input destination review findings 2026-10-14 14:12:18 +00:00
945e111c4e Complete input destination implementation audit 2026-10-14 14:06:38 +00:00
2d8df22dd1 Add mounted collection consumer examples 2026-10-14 13:59:37 +00:00
53601e68ed Expose input destinations through the public facade 2026-10-14 13:56:22 +00:00
751787ec24 Verify mounted inheritance and sensitivity integration 2026-10-14 13:50:08 +00:00
f50122d9f8 Verify mounted provenance and resource contexts 2026-10-14 13:38:56 +00:00
c71faf2065 Count destination wrappers in shared input limits 2026-10-14 13:31:32 +00:00
2f5d57ce1d Verify mounted diagnostic paths and locations 2026-10-14 13:25:55 +00:00
a1c557603f Verify mounted composition and version ownership 2026-10-14 13:24:06 +00:00
a2fab66aba Carry destinations through imported configuration 2026-10-14 13:19:31 +00:00
2f1f7f4b8a Integrate private destinations for layered inputs 2026-10-14 13:14:06 +00:00
667a536ae7 Add private input destination placement helpers 2026-10-14 13:09:06 +00:00
ea55ab43ce Record input destination decision and baseline 2026-10-14 13:05:09 +00:00
d3d468a8b4 Plan configuration input destination implementation 2026-10-14 12:48:08 +00:00
6353598bd6 Settle input destination roadmap decisions 2026-10-14 12:40:06 +00:00
3c2d7facb3 Plan configuration input destinations 2026-10-14 03:27:34 +00:00
6fffde4af8 Prepare ConfigKit v0.7.1 release 2026-10-11 01:14:23 +00:00
9e04a397c4 Upgrade ReleaseKit to v0.4.1 2026-10-11 01:10:26 +00:00
c54a9bf51e Upgrade ReleaseKit to v0.4.0 2026-10-10 23:51:44 +00:00
8f3396d6f0 Retire completed diff plans for release preparation 2026-10-09 14:23:20 +00:00
1c2610c2c0 Prepare release notes for v0.7.0 2026-10-09 14:21:37 +00:00
92025d65cb Correct diff masking for historical descendants 2026-10-09 13:22:40 +00:00
a4b5a6840a Record effective diff review findings 2026-10-09 12:57:40 +00:00
2ebdfb539b Document and audit effective configuration diffs 2026-10-09 12:42:17 +00:00
a37e6895e1 Add effective diff consumer examples 2026-10-09 12:37:18 +00:00
8ae542b3a3 Expose effective diffs through the root facade 2026-10-09 12:34:11 +00:00
174efb65f6 Assemble the safe engine diff operation 2026-10-09 12:30:20 +00:00
756149beaa Track hidden changes behind redaction 2026-10-09 12:24:01 +00:00
e5dddef204 Mask sensitive structural replacements 2026-10-09 12:22:36 +00:00
ade5d6a273 Prepare paired safe snapshots for diffs 2026-10-09 12:20:15 +00:00
c20ea4b916 Index combined diff sensitivity 2026-10-09 12:16:13 +00:00
2e17e365a1 Prepare canonical configuration diff regions 2026-10-09 12:14:47 +00:00
fcf914a2ab Record effective diff baseline evidence 2026-10-09 12:12:12 +00:00
3697a51124 Accept the effective configuration diff decision 2026-10-09 12:10:56 +00:00
9194c5b52a Plan effective configuration diff implementation 2026-10-09 05:09:25 +00:00
e82b1c9054 Finalize the effective configuration diff roadmap 2026-10-09 05:02:49 +00:00
6f59cdd237 Plan structured effective configuration diffs 2026-10-09 04:55:29 +00:00
1a885934c2 Prepare release notes for v0.6.0 2026-10-09 04:29:32 +00:00
5917cebf50 Retire completed roadmaps for release preparation 2026-10-09 04:28:16 +00:00
8e91a2b16b Upgrade ReleaseKit to v0.3.0 2026-10-09 04:26:30 +00:00
08402d098b Adopt updated release toolchain policy 2026-10-09 04:23:15 +00:00
53b79528d9 Keep reference algorithms in tests 2026-10-09 04:14:45 +00:00
f849e06b77 Correct the unindexed sensitivity fixture 2026-10-09 04:11:50 +00:00
b37948cfea Reuse parsed sensitivity paths 2026-10-09 04:10:41 +00:00
9323f89ad1 Record performance implementation review findings 2026-10-09 04:07:46 +00:00
6c62226838 Record performance audit completion 2026-10-09 04:01:14 +00:00
799ebab5a6 Index resolution and origin membership 2026-10-09 03:59:16 +00:00
c8546ae599 Index inherited step membership 2026-10-09 03:56:29 +00:00
d32c94eca3 Reuse frozen inspection mapping lookups 2026-10-09 03:54:14 +00:00
344f4f73d7 Index inheritance history lookups 2026-10-09 03:52:07 +00:00
5fedc11c8f Prepare coalesced resolution replay 2026-10-09 03:49:43 +00:00
5b8b7a2415 Index CLI override validation 2026-10-09 03:45:43 +00:00
e591e97f22 Integrate indexed sensitivity propagation 2026-10-09 03:42:29 +00:00
71e142afb4 Add indexed secret dependency closure 2026-10-09 03:41:27 +00:00
ca1ba04676 Collect sensitivity directly from document trees 2026-10-09 03:39:12 +00:00
528495c75a Index resolution metadata updates 2026-10-09 03:36:29 +00:00
aa3b4a5881 Index resolution coverage and document reads 2026-10-09 03:32:30 +00:00
db2d8ed217 Add private canonical path prefix index 2026-10-09 03:30:01 +00:00
d34a98d34b Precompute canonical paths before sorting 2026-10-09 03:27:42 +00:00
9ac65ccc08 Compare canonical paths by token spelling 2026-10-09 03:23:08 +00:00
edc6660654 Detect inheritance directives without paths 2026-10-09 03:20:24 +00:00
a2084df11a Make explicit metadata queries lightweight 2026-10-09 03:18:50 +00:00
b59f002314 Record performance audit baseline evidence 2026-10-09 03:15:39 +00:00
52793301ba Plan the performance improvements 2026-10-09 03:13:22 +00:00
0c77deed05 Record performance audit and optimization candidates 2026-10-09 02:09:25 +00:00
48619de024 Verify startup loading improvements 2026-10-09 01:19:13 +00:00
c546124029 Combine final metadata traversal 2026-10-09 00:52:55 +00:00
673faea097 Reduce path formatting allocations 2026-10-09 00:34:21 +00:00
91ae4fa7ef Measure startup configuration loading 2026-10-09 00:14:19 +00:00
f1722274c0 Add an ordered roadmap for startup loading performance 2026-10-08 23:58:35 +00:00
d53021ea2c Request more efficient configuration loading for PromptKit 2026-10-08 23:50:02 +00:00
a22aaa5dfa Mark ReleaseKit adoption complete 2026-10-08 17:08:53 +00:00
b1ecd9a078 Clarify release command exit statuses 2026-10-08 17:08:35 +00:00
84b59c690d Correct release recovery endpoint checks 2026-10-08 17:08:00 +00:00
ac90b9835b Record ReleaseKit adoption review findings 2026-10-08 17:01:58 +00:00
f887fac809 Complete ReleaseKit endpoint and recovery validation 2026-10-08 16:55:10 +00:00
c03457c814 Verify ReleaseKit against a disposable remote 2026-10-08 16:51:09 +00:00
e9b6b63e5e Retire duplicated release scripts and tests 2026-10-08 16:46:28 +00:00
1dc50d8cdf Replace the release procedure with ReleaseKit 2026-10-08 16:42:56 +00:00
0e92085002 Align release policy with ReleaseKit 2026-10-08 16:37:10 +00:00
f96f2b8295 Add the ReleaseKit source profile 2026-10-08 16:35:58 +00:00
1917dcf921 Declare ReleaseKit as a Go tool dependency 2026-10-08 16:34:58 +00:00
0ac4731903 Separate Go minimum from release toolchain 2026-10-08 16:23:25 +00:00
c42930c6d5 Plan ReleaseKit migration stages 2026-10-08 13:48:36 +00:00
2d3ea588f1 Resolve ReleaseKit adoption decisions 2026-10-08 13:40:50 +00:00
5aafd943b4 Plan Go toolchain updates for releases 2026-10-08 13:33:43 +00:00
c0605fab44 Plan ReleaseKit adoption 2026-10-08 13:12:18 +00:00
dc09959cca Prepare ConfigKit v0.5.0 release 2026-09-30 00:18:40 +00:00
83c59ea7d3 Fix nested inheritance history and budget accounting 2026-09-30 00:04:02 +00:00
b45f7a0410 Record sibling inheritance review findings 2026-09-30 00:00:09 +00:00
2ca1f80094 Document and benchmark sibling inheritance 2026-09-29 23:13:50 +00:00
df9a02539c Activate sibling inheritance in configuration loading 2026-09-29 23:04:13 +00:00
501d59f21b Preserve secret sensitivity across sibling inheritance 2026-09-29 22:56:10 +00:00
f916e7d1f9 Preserve sibling inheritance in configuration history and explanations 2026-09-29 22:52:51 +00:00
462e57df3e Preserve inherited source origins and transfer evidence 2026-09-29 22:40:49 +00:00
4207110e2f Resolve nested inheritance in destination scopes 2026-09-29 22:31:07 +00:00
fdbc306cfb Resolve sibling inheritance with memoized parent templates 2026-09-29 22:23:48 +00:00
78d4b59bbe Share recursive mapping merge across configuration assembly 2026-09-29 22:20:10 +00:00
f564fef84c Plan sibling inheritance implementation 2026-09-29 21:37:38 +00:00
01e38b5840 Record agreed inheritance reference and presence rules 2026-09-29 21:18:48 +00:00
ac1eec2e30 Revise inheritance roadmap around built-in sibling references 2026-09-29 21:10:04 +00:00
0a4b774bfe Retire completed plans and outline named inheritance 2026-09-29 20:11:42 +00:00
b604aa167b Fix underlay provenance and resource path handling 2026-09-29 20:03:39 +00:00
15f5083282 Record underlay implementation review findings 2026-09-29 19:16:18 +00:00
cbf031aedc Document and demonstrate native and layered configuration loading 2026-09-29 18:22:45 +00:00
34e18c84c1 Validate source equivalence and layered scaling 2026-09-29 18:15:10 +00:00
36362ee6af Provide passive per-source resource contexts 2026-09-29 18:08:27 +00:00
5a77728a81 Expose layered configuration loading and inspection 2026-09-29 17:58:15 +00:00
1cdf90d831 Compose grouped underlays in shared load lifecycle 2026-09-29 17:52:20 +00:00
90c6e8ed7e Add snapshotted mapping configuration sources 2026-09-29 17:43:19 +00:00
1ff3c812a7 Expose native filesystem sources through public loading API 2026-09-29 17:36:20 +00:00
6111cb23a1 Unify host and native filesystem acquisition 2026-09-29 17:29:01 +00:00
de28718941 Reuse owned assembly across configuration layers 2026-09-29 17:13:14 +00:00
8d9a3a8dfa Index mapping assembly for composition and overlays 2026-09-29 17:03:02 +00:00
27df838513 Plan filesystem sources and underlays 2026-09-29 16:50:11 +00:00
36172ed20e Clarify ConfigKit design goals and priorities 2026-09-29 16:10:16 +00:00
e1ce24aabb Focus the source roadmap on recursive underlays 2026-09-29 16:06:31 +00:00
e4bbedc695 Preserve version semantics in the layering roadmap 2026-09-29 15:52:20 +00:00
798091b276 Align layering roadmap with consistent version semantics 2026-09-29 15:45:21 +00:00
5c1aa1814c Document filesystem sources and layered loading roadmap 2026-09-29 15:25:23 +00:00
ec26591b27 Request named configuration inheritance for PromptKit 2026-09-29 15:07:21 +00:00
99338baabb Record PromptKit filesystem and layered loading requests 2026-09-29 14:54:44 +00:00
67c0369018 Prepare ConfigKit v0.4.2 release 2026-09-28 01:44:54 +00:00
a36ed3deba Fix release destination handling and verification example 2026-09-28 01:42:06 +00:00
d750e7325c Record release workflow review findings 2026-09-28 01:31:38 +00:00
3d0093e1fd Align release policy and operational documentation 2026-09-28 01:13:09 +00:00
299ebc21a9 Add guarded single-command release publication 2026-09-28 01:07:30 +00:00
ad2f07db5f Simplify release validation gates 2026-09-28 00:57:25 +00:00
d6fe498652 Plan the single-command release workflow 2026-09-28 00:52:16 +00:00
faf5ff8fa1 Correct policy references and restore upstream wording 2026-09-28 00:45:13 +00:00
897203dd8f Align release policy and complete policy adoption 2026-09-28 00:32:01 +00:00
b5a919b8c5 Align agent architecture and testing policies 2026-09-28 00:28:55 +00:00
5f4a8f1919 Align policy provenance and licensing documentation 2026-09-28 00:27:03 +00:00
12a03dbb9a Plan staged policy documentation alignment 2026-09-28 00:21:11 +00:00
6f372e0dfe Document policy alignment and release workflow roadmaps 2026-09-28 00:12:25 +00:00
208 changed files with 26003 additions and 1999 deletions

View File

@@ -1,5 +1,25 @@
Please review `docs/development.md` for initial orientation in this repository <!-- codebase-memory-mcp:start -->
and follow its task-specific reading guide. # Codebase Knowledge Graph (codebase-memory-mcp)
Review and follow `docs/policy/agents.md` before recommending, planning, or This project uses codebase-memory-mcp to maintain a knowledge graph of the codebase.
implementing any changes to this repository. ALWAYS prefer MCP graph tools over grep/glob/file-search for code discovery.
## Priority Order
1. `search_graph` — find functions, classes, routes, variables by pattern
2. `trace_path` — trace who calls a function or what it calls
3. `get_code_snippet` — read specific function/class source code
4. `query_graph` — run Cypher queries for complex patterns
5. `get_architecture` — high-level project summary
## When to fall back to grep/glob
- Searching for string literals, error messages, config values
- Searching non-code files (Dockerfiles, shell scripts, configs)
- When MCP tools return insufficient results
<!-- codebase-memory-mcp:end -->
Start with `docs/development.md` for orientation and use its policy reading guide
to select the policies applicable to the task. Do not read the entire suite by
default.
Read and follow `docs/policy/agents.md` before recommending, planning, or
implementing repository changes.

View File

@@ -18,3 +18,8 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details. GNU Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms. 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.

View File

@@ -5,18 +5,33 @@ An opinionated Go library for applications that use YAML configuration files.
Linux is release-validated; macOS support is best-effort. See the 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) for details.
ConfigKit's initial loader accepts one strict YAML ConfigKit's simple `Load` accepts one strict YAML source (or explicit
source (or explicit defaults-only input), optionally traverses explicit path and glob defaults-only input). `LoadLayered` additionally combines explicit lower-priority
imports, strictly composes their disjoint definitions, applies typed defaults, and source groups with an independently selected main input. Sources can be host
can overlay one selected fixed overlay and check an application schema version. It files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
returns detached metadata and safe diagnostics. Enabled path and glob 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, Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply above overlays. Value explanation is available through CLI override records apply above overlays. Value explanation is available through
detached structured records and deterministic redacted output. 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, and definitions can
register selected sequences to append records with distinct string IDs during
strict composition. Higher-priority sequences still replace lower-priority
sequences as whole values. See the [sequence contract](docs/config.md#strict-document-composition),
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
```go ```go
type Config struct { type Config struct {
@@ -33,14 +48,48 @@ func ReadConfig(path string) (Config, error) {
} }
``` ```
The [configuration contract](docs/config.md) describes implemented behavior. The ## Design philosophy
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.
ConfigKit is licensed under the [LGPL v3.0 or later](COPYRIGHT). 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.
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 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 and explicit CLI overrides sit
above it. The [configuration contract](docs/config.md#strict-document-composition)
owns the detailed combination rules and input-specific constraints. At
definition-registered paths only, strict composition can append records with
distinct explicit IDs; ordinary and unregistered sequences keep their conflict
or whole-value replacement behavior.
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
these priorities and how genuine new needs can justify extending the shared
model. The [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.
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
[Open Source attribution notices](docs/oss/attribution.md) are available. [Open Source attribution notices](docs/oss/attribution.md) are available.

View File

@@ -43,16 +43,57 @@ func toEngineLocations(values []Location) []engine.Location {
return result return result
} }
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
} }
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} 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)}
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil {
return nil
}
return &SourceVersionCheck{
Path: fromEnginePath(value.Path),
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
}
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
if value == nil {
return nil
}
return &engine.SourceVersionCheck{
Path: toEnginePath(value.Path),
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
} }
func fromEngineOrigin(value engine.Origin) Origin { func fromEngineOrigin(value engine.Origin) Origin {
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
if value.Inheritance != nil {
result.Inheritance = make([]InheritanceStep, len(value.Inheritance))
for index, step := range value.Inheritance {
result.Inheritance[index] = InheritanceStep{
From: fromEnginePath(step.From), To: fromEnginePath(step.To), Reference: fromEnginePath(step.Reference),
Locations: fromEngineLocations(step.Locations),
}
}
}
return result
} }
func toEngineOrigin(value Origin) engine.Origin { func toEngineOrigin(value Origin) engine.Origin {
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)} result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
if value.Inheritance != nil {
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
for index, step := range value.Inheritance {
result.Inheritance[index] = engine.InheritanceStep{
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
Locations: toEngineLocations(step.Locations),
}
}
}
return result
} }
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic { func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)} return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}

110
development_check_test.go Normal file
View File

@@ -0,0 +1,110 @@
package configkit
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
for _, scriptName := range []string{"check.sh"} {
t.Run(scriptName, func(t *testing.T) {
script, err := os.ReadFile("scripts/" + scriptName)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"success", "failed-go-check", "untracked-whitespace"} {
fail := name != "success"
t.Run(name, func(t *testing.T) {
root := t.TempDir()
bin := t.TempDir()
log := filepath.Join(bin, "gofmt.args")
calls := filepath.Join(bin, "go.calls")
for _, directory := range []string{"scripts", "nested"} {
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
t.Fatal(err)
}
}
write := func(path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "scripts", scriptName), script, 0700)
write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600)
write(filepath.Join(root, "go.sum"), nil, 0600)
write(filepath.Join(root, "obsolete.go"), []byte("package obsolete\n"), 0600)
files := []string{"nested/a space.go", "nested/a\nnewline.go"}
for _, file := range files {
write(filepath.Join(root, file), []byte("package example\n"), 0600)
}
if name == "untracked-whitespace" {
write(filepath.Join(root, "notes.md"), []byte("trailing space \n"), 0600)
}
// Isolate expensive toolchain commands: this test owns shell orchestration,
// not Go's compiler or the production tests exercised by the real checker.
write(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s/%s %s\\n' \"${GOOS-native}\" \"${GOARCH-native}\" \"$*\" >> \"$CONFIGKIT_TEST_GO_LOG\"\nif [ \"${CONFIGKIT_TEST_FAIL-}\" = yes ] && [ \"${1-}\" = test ]; then exit 37; fi\n"), 0700)
write(filepath.Join(bin, "gofmt"), []byte("#!/bin/sh\nprintf '%s\\000' \"$@\" > \"$CONFIGKIT_TEST_FMT_LOG\"\n"), 0700)
init := exec.Command("git", "init", "-q")
init.Dir = root
if out, err := init.CombinedOutput(); err != nil {
t.Fatalf("git init: %v %s", err, out)
}
// HEAD is required by the whitespace gate. Commit only the module baseline.
for _, args := range [][]string{{"add", "go.mod", "go.sum", "obsolete.go"}, {"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "Test fixture"}} {
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git fixture: %v %s", err, out)
}
}
if err := os.Remove(filepath.Join(root, "obsolete.go")); err != nil {
t.Fatal(err)
}
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
// Log only targets selected by the script, not inherited host settings.
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
if name == "failed-go-check" {
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=yes")
}
out, err := cmd.CombinedOutput()
if fail {
if err == nil {
t.Fatal("checker ignored failed Go test")
}
} else if err != nil {
t.Fatalf("checker: %v %s", err, out)
}
arguments, err := os.ReadFile(log)
if err != nil {
t.Fatal(err)
}
parts := bytes.Split(arguments, []byte{0})
for _, file := range files {
found := false
for _, arg := range parts {
if string(arg) == file {
found = true
}
}
if !found {
t.Fatalf("filename was split: %q", file)
}
}
invoked, err := os.ReadFile(calls)
if err != nil {
t.Fatal(err)
}
if fail && strings.Contains(string(invoked), "vet ./...") {
t.Fatal("checker continued after failed test")
}
})
}
})
}
}

53
diff.go Normal file
View File

@@ -0,0 +1,53 @@
package configkit
import "gitea.maximumdirect.net/eric/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.
type Difference struct {
Path Path
Left ValueSnapshot
Right ValueSnapshot
HiddenChanged bool
}
// Diff compares the frozen effective values of two successful loads of the
// same configuration type. 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.
//
// Both results must be non-nil successful loads with inspection state; nil or
// zero results return an error and no records. Load errors are returned by
// Load, so callers must handle those before calling Diff. Snapshots are safe
// for presentation but are not a complete reloadable configuration.
func Diff[T any](left, right *Result[T]) ([]Difference, error) {
var leftInspection, rightInspection *engine.Inspection
if left != nil {
leftInspection = left.inspection
}
if right != nil {
rightInspection = right.inspection
}
values, err := engine.Diff(leftInspection, rightInspection)
if err != nil {
return nil, err
}
differences := make([]Difference, len(values))
for index, value := range values {
differences[index] = Difference{
Path: fromEnginePath(value.Path),
Left: fromEngineValueSnapshot(value.Left),
Right: fromEngineValueSnapshot(value.Right),
HiddenChanged: value.HiddenChanged,
}
}
return differences, nil
}

View File

@@ -0,0 +1,118 @@
# ADR 0007: Add Source-Backed Underlays with Consistent Recursive Precedence
## Status
Proposed draft for review. Completed planning and remediation records are
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
[configuration contract](../config.md#layered-loading) owns current behavior.
This extends [ADR 0001](0001-separate-composition-and-overlay.md) and preserves
its distinction between strict composition and intentional overlay.
## Date
2026-09-29
## Context
Configuration-backed defaults provide lower-priority settings that applications
can override. These sources carry imports, locations, and relative-resource
contexts. Integrating them into ConfigKit's loading lifecycle gives their values
the same presence tracking and inspectable provenance as other configuration
inputs.
The [architecture goals](../policy/architecture.md#success-criteria) prioritize
one coherent, predictable configuration model with consistent rules across
source representations and loading interfaces. Consumers should load once,
receive a complete validated configuration, and inspect its final values' origins.
Reducing integration code is important subject to those principles.
## Decision
### Add underlays to the existing precedence model
Introduce explicitly supplied source-backed underlays between typed defaults
and main configuration. The proposed precedence, from lowest to highest, is:
**Typed application defaults < source underlays < strictly composed main
configuration < selected overlay < explicit CLI overrides.**
Typed defaults remain the Go-value baseline. Underlays supply lower-priority
configuration with source provenance and, where applicable, imports and resource
context. This ordering describes value precedence; typed defaults fill fields
absent from the assembled input under the existing defaulting rules.
Preserve strict composition of the main document and its imports before applying
the resulting configuration over underlays. Independent contributions within an
underlay tier also compose strictly. Explicitly ordered tiers express intentional
precedence. Strict composition reports conflicting contributions within each tier.
Across precedence tiers, reuse the existing recursive mapping-overlay semantics.
Omitted children retain lower-priority input; existing scalar, sequence, null,
and cross-kind replacement semantics remain unchanged. Reusing these operations
keeps the combination rules consistent across the loading model.
### Keep source representation separate from composition semantics
Native `fs.FS` and programmatic inputs join the shared loading model.
Equivalent inputs using the same
operation have the same presence and precedence semantics regardless of medium.
Programmatic values enter directly as labeled configuration sources.
Retain the contributing source's identity and resource context through layering.
A retained underlay value keeps its originating source's resource context.
Consumers remain responsible for opening and interpreting their domain resources
under the supplied filesystem's lifetime and access semantics. Exact
context-access and input APIs remain roadmap decisions.
### Deliver one final configuration and its inspection metadata
Assemble effective input, then run the typed loading lifecycle once for the load.
Preserve explicit zero,
false, empty, and null input versus omission through configuration and metadata
together.
Extend existing provenance and history through underlays, defaults, and recorded
application resolution. The returned result includes the completed combination,
defaulting, and provenance work. Domain resolution and validation
remain application-owned within the recorded lifecycle, consistent with
[ADR 0003](0003-preserve-presence-and-resolution-history.md).
Preserve the existing opt-in schema-version ownership, path protections, and
check timing before precedence
assembly and typed decoding. The roadmap owns how layered input identifies that
owner when version checking is configured.
Favor near-linear processing in input, retained history, and output size through
incremental assembly of load-owned state. This is an engineering objective
subordinate to clarity and semantic consistency. Preserve the root-owned facade and
private implementation boundary established by [ADR 0006](0006-public-facade.md).
## Alternatives considered
- **Leave bundled defaults to consumers.** Avoids extending loading, but makes
consumers reproduce merging, presence tracking, and provenance reconciliation.
It does not meet the goal of obtaining a complete configuration in one load.
- **Offer recursive or atomic combination per layer.** Makes both behaviors
explicit and could serve more consumers, but expands the semantic surface and
its interactions with presence and provenance without a demonstrated need that
outweighs a single consistent rule. A future concrete requirement may justify
reconsideration; this feature does not add the option.
- **Flatten all documents into last-wins layers.** Simplifies ordering but turns
accidental main/import conflicts into overrides, weakening the intentional
distinction established by ADR 0001.
## Consequences
Consumers gain source-backed defaults within the shared loading lifecycle.
Strict contributions and intentional precedence remain distinct, with consistent
combination rules across source media. Presence and provenance remain
part of the same loading lifecycle as the final values.
Higher-priority mappings retain omitted lower-priority children, so application
validation assesses the final combined configuration.
Implementation must integrate source context and underlay history into the shared
engine with attention to assembly and history-retention costs. Current behavior
documentation will change when the feature lands. The roadmap retains ownership
of the remaining API decisions.

View File

@@ -0,0 +1,82 @@
# ADR 0008: Synchronous, Complete-on-Return Configuration Loading
## Status
Proposed draft for review. The
[configuration contract](../config.md#completion-and-resource-lifetime) describes
the implemented loading lifecycle and
[source resource contexts](../config.md#source-resource-contexts).
## Date
2026-09-29
## Context
Applications need a clear point at which configuration is ready for use.
Completing configuration processing and inspection metadata together gives
applications a coherent result whose values and origins agree. A bounded loading
lifecycle also makes resource ownership and startup failures straightforward to
reason about.
ConfigKit's [architecture goals](../policy/architecture.md#success-criteria)
emphasize complete configuration, inspectable provenance, predictable behavior,
and minimal consumer bookkeeping. Applications own their execution lifecycle and
the timing of configuration acquisition.
## Decision
Make loading a synchronous, complete-on-return operation. Within each load,
ConfigKit completes input assembly, defaulting, typed decoding, configured
application resolution, secret lookup, validation, and snapshotting before
returning the final configuration and metadata. Reported failures return
diagnostics and a nil result.
Release all library-owned loading handles before returning on success or failure.
The resulting configuration and metadata are independently usable, caller-owned
data with a retained inspection snapshot. Caller-supplied readers and filesystems
remain under caller ownership and lifetime control.
After the call completes, ConfigKit remains inactive until explicitly invoked
again. Applications normally load once at or near startup and may initiate
additional loads when needed. Each load follows the same lifecycle and source
reuse contracts. Inspection, rendering, and separately requested operations run
in response to explicit application calls.
For the planned resource-context capability, retain source context as passive
information or a borrowed caller-supplied filesystem. Subsequent resource access
acquires its own handles with explicit ownership. This preserves a clear boundary
between configuration completion and application resource use.
These choices give consumers one completion boundary for configuration and
provenance, predictable resource lifetimes, and control over when ConfigKit runs.
Application callbacks remain responsible for their own effects and resources.
## Alternatives considered
- **Background watching or refresh.** Can automate responses to source changes,
but adds scheduling, shutdown, publication, and resource-lifetime concerns to
the library. Explicit calls keep those responsibilities with the application.
- **Lazy completion after return.** Can defer work until particular values are
used, but moves configuration failures into application execution and makes
the readiness of values and metadata harder to establish.
- **Transactional atomic loading.** Could imply simultaneous observation of all
external inputs and rollback of callback effects. Filesystems, environment
variables, and application callbacks do not share a transaction mechanism.
Synchronous completion provides the required usable-result boundary without
promising those external guarantees.
- **Retain loading handles in results.** Can preserve open filesystem access,
but gives configuration results an ongoing resource lifecycle and cleanup
obligation. Passive source context supports later explicit resource access
while keeping loading resources scoped to the load call.
## Consequences
Applications can treat successful return as the point at which the final
configuration and its metadata are ready for use. Startup loading remains simple,
and additional loads have an explicit application-owned trigger.
Configuration inspection operates on retained data. Resource access observes the
backing resource when explicitly requested and has its own failure and ownership
boundary. Implementations of new source kinds and layering features preserve the
same completion boundary, including cleanup on reported load failures.

View File

@@ -0,0 +1,76 @@
# ADR 0009: Resolve Sibling Inheritance After Source Assembly
## Status
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
owns current behavior. The completed implementation plan is preserved in Git
history at `83c59ea`.
## Date
2026-09-29
## Context
Named configuration mappings often share settings while retaining individual
overrides. ConfigKit already distinguishes strict composition of independent
source contributions from recursive overlay between explicit precedence tiers.
Doing another untracked merge in applications would lose omission, source
context, and historical explanation information. A shared structural operation
must preserve the same presence and provenance model as the rest of loading.
Inheritance also interacts with source precedence, nested namespaces, version
ownership, and secrets. Resolving it before source assembly would validate
superseded references and copy values that a later tier should replace. Fully
resolving nested relationships inside a reusable parent would bind them to the
wrong sibling scope when copied into a child.
## Decision
Reserve `extends` as a built-in input key on mappings. A string names one exact
sibling mapping; null cancels the relationship. Complete source acquisition,
strict composition, selected overlay, and CLI precedence first. Then resolve
each effective relationship in the parser-independent document tree, before
typed defaults and decoding. A child recursively overlays its own payload on
its resolved parent using the existing mapping-combination rules. This creates
neither a source tier nor a configurable merge strategy.
Resolve enclosing sibling chains before nested relationships. Reuse memoized
parent templates with unresolved nested instructions; evaluate those instructions
at each destination. Preserve original source applications separately from
structural transfer/control evidence. Effective origins carry concrete
source-to-destination derivation, and inspection projects relevant inheritance
events between source replay and typed reconciliation. Inherited input counts as
explicit presence, while direct replacement retains its own effective origin.
The existing main-input version check remains at its original point. Reject
structural contributions or directive consumption affecting its protected path.
Classify sensitivity at corresponding source and destination paths and retain
historical redaction. Secret references remain unresolved input until the
existing lookup point. Keep all machinery behind the root-owned public facade.
## Alternatives considered
- **Application-owned inheritance after `Load`.** Leaves every consumer to
recreate ordering, presence, provenance, error reporting, and redaction after
ConfigKit has already declared a result complete.
- **Resolve each source before precedence assembly.** Makes superseded
references active and can copy a parent definition before higher-priority
input finishes changing it.
- **Resolve nested relationships in a cached parent subtree.** Shares more
ready-made output but binds nested sibling lookups to the parent's scope,
giving incorrect values at a child destination.
- **Registered collections, configurable keywords, or multiple-parent merge
modes.** Increase public setup and semantic branches without a requirement
that warrants diverging from one structural rule.
## Consequences
Consumers can load a configuration once and inspect direct, inherited,
defaulted, and application-derived values through the existing result surfaces.
`extends` is no longer an ordinary application field, so existing configurations
using that key as data must migrate. Expanding many children can materialize
substantially more output than the input contains; projection and retained-history
budgets bound that work without charging copies as newly acquired sources.
Applications still own domain validation, entry selection, and runtime resource
use.

View File

@@ -0,0 +1,99 @@
# ADR 0010: Compare Frozen Effective Configuration Snapshots
## Status
Accepted and implemented. The
[configuration contract](../config.md#effective-configuration-diffs) owns current
behavior. Completed planning and remediation records are preserved in Git
history at `1c2610c` under `docs/roadmap/`.
## Date
2026-10-09
## Context
Applications need to inspect how two completed configurations differ, including
the effective impact of an overlay after defaults and application resolution.
ConfigKit already retains canonical typed inspection snapshots, semantic paths,
presence information, and sensitivity classification for successful loads.
Extending this inspection model lets applications share configuration comparison
and safe disclosure while keeping their presentation and domain interpretation.
The operation should follow ConfigKit's existing equality, ownership, and secret
boundaries so consumers can reason about loading and inspection consistently.
## Decision
Provide structured effective-value comparison between successful results of the
same configuration type. Compare their retained canonical inspection snapshots
through the root-owned public facade. Each comparison uses the completed load
state and returns detached records with a semantic path and safe left and right
value snapshots.
Use canonical value equality and the existing structural comparison rules.
Mappings compare recursively, sequences report atomic changes, and scalar or
kind replacements report the affected region. Presence distinguishes additions
and removals from explicit null. Equal effective values produce an empty diff;
source attribution, explicit-input status, and history remain separately
inspectable through each result. Canonical pointer ordering makes reporting
deterministic.
Detect changes before masking values. Apply sensitivity from both loads to both
returned sides, including historical and dependency-derived classification.
Preserve public content where the shapes permit corresponding sensitive regions
to be identified. When a structural replacement prevents safe correspondence,
mask the complete reported replacement region on both present sides. Absence
retains its own representation.
Keep masking and hidden-change information distinct. Each record indicates
whether an actual change is withheld by redaction, allowing applications to
display a changed-sensitive-value indicator even when safe snapshots render
identically. Public-only changes alongside unchanged masked content retain that
distinction.
Typed secret references participate through their configured environment names.
Resolved credential bytes remain behind the existing explicit secret accessor.
The comparison uses the same frozen inspection boundary as effective rendering
and explanation.
ConfigKit owns comparison and paired safe disclosure. Applications own labels,
layout, and rendering of the returned changed regions. Reusing the existing
snapshot representation and engine boundary keeps the extension consistent with
the library's established inspection model.
## Alternatives considered
- **Compare input text or separately rendered YAML.** Supports familiar textual
displays, but input text precedes composition and resolution, and independently
redacted output can hide changes or apply different disclosure rules. Canonical
comparison with paired masking represents the completed configuration.
- **Leave comparison to applications using returned Go values.** Offers direct
access to application types, but duplicates structural and redaction machinery
and observes caller mutation. Retained inspection snapshots preserve the
completed load's values and established ownership boundary.
- **Align sequence elements and return context-rich rendering data.** Enables
finer-grained displays, but requires additional correspondence and disclosure
rules. Atomic sequence records and existing value snapshots satisfy the
selected changed-region workflows with a smaller public model.
- **Compare resolved credentials or retain credential-derived tokens.** Could
detect rotation behind an unchanged reference, but expands retained sensitive
state and introduces a separate trusted comparison mechanism. Reference-name
comparison preserves the existing inspection boundary.
## Consequences
Applications can compare independently loaded configurations using ConfigKit's
shared equality, presence, and disclosure rules. Existing composition and load
semantics continue to determine the values being compared. Caller mutation of a
returned configuration leaves its retained comparison snapshot unchanged.
Paired redaction can conceal public content within a structurally incompatible
replacement. The changed region and hidden-change indicator still communicate
its effective impact. Credential rotation behind an unchanged environment name
produces equal reference values for comparison.
The implementation requires operation-local disclosure preparation and focused
coverage for presence, shape changes, historical sensitivity, and detached
results. Applications retain control of presentation; changed-region records
supply the safe data needed for that responsibility.

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,13 +12,21 @@ ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract | | Constructor | Current contract |
| --- | --- | | --- | --- |
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. | | `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. | | `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. | | `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
| `Mapping(label, values)` | Requires a safe nonempty label and a non-nil root `map[string]any`. Snapshots supported built-in values into an owned mapping document at construction; `{}` is valid. Constructor conversion failures become safe deferred diagnostics. |
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. | | `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
The zero `Source` is invalid. Constructor argument failures are retained inside the The zero `Source` is invalid. Constructor argument failures are retained inside the
opaque source and become safe structured diagnostics when the source is consumed. opaque source and become safe structured diagnostics when the source is consumed.
File sources can observe file changes between consumptions; byte sources cannot. File and filesystem sources can observe changes between consumptions; byte and
mapping sources cannot. Mapping accepts nil, built-in string, boolean, signed or
unsigned integer, finite float, `[]any`, and `map[string]any` values recursively.
Typed nil maps and slices represent explicit null; empty collections remain empty.
Keys are sorted for stable provenance, integer decimal spelling stays exact, and
cycles or unsupported types are rejected without exposing values. Mapping input
uses the same directive and version rules as YAML, not CLI override restrictions.
Each source is limited to 8 MiB. A load using imports or an active overlay is limited Each source is limited to 8 MiB. A load using imports or an active overlay is limited
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
@@ -26,6 +34,8 @@ edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
1,000,000 visited directory entries, including nonmatches. Reads request at most the 1,000,000 visited directory entries, including nonmatches. Reads request at most the
remaining allowance plus one byte so excess data is rejected rather than silently remaining allowance plus one byte so excess data is rejected rather than silently
truncated. Data returned together with a read error is not accepted as partial input. truncated. Data returned together with a read error is not accepted as partial input.
For mapping sources, key and string bytes use the same accounting as programmatic
overrides, and the converted tree uses the same node and depth bounds.
## Single-source loading ## Single-source loading
@@ -44,33 +54,245 @@ The current lifecycle is:
```text ```text
validate options → parse the selected source → traverse/compose enabled imports validate options → parse the selected source → traverse/compose enabled imports
→ build schema → check the optional application version → apply a selected overlay → build schema → check the optional application version → apply a selected overlay
→ apply explicit CLI overrides → create and clone defaults → strictly decode → apply explicit CLI overrides → expand sibling inheritance
→ create and clone defaults → strictly decode
→ recorded application resolution → recorded application resolution
→ resolve typed secret references → semantic validation → classify secrets → resolve typed secret references → semantic validation → classify secrets
→ snapshot configuration and metadata → snapshot configuration and metadata
``` ```
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic `File`, `Filesystem.File`, `Bytes`, `Reader`, and `Mapping` each contribute the first base source with deterministic
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward. ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
`NoSource` skips parsing, supplies an absent version to a configured version checker, `NoSource` skips parsing, supplies an absent version to a configured version checker,
contributes no source, and continues through defaults, decode, and projection. A contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input. missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver, `Options` accepts the application definition, ordinary-source `Destination` and
semantic validator, explicit path and glob imports, one selected overlay, and `PlacementMode`, explicit path and glob imports, one selected overlay, filesystem
filesystem context. It also accepts explicit CLI override records, static secret context, explicit CLI overrides, and shared source-version controls. The definition
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays, may register strict sequence composition paths through
and filesystem context as invalid combinations but accepts overrides. Input `SequenceCompositions`. Its zero-valued placement mode places the source mapping
containing a reserved `configkit` mapping key is rejected while imports are at the destination; `sequence-item` explicitly wraps the mapping as one item at a
disabled. non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. `NoSource` rejects
imports, active overlays, and filesystem context as invalid combinations but
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
while imports are disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating `Metadata`, a defensive source list, and defensive nonfatal diagnostics. Each source
record's `Kind` identifies file, filesystem, bytes, reader, mapping, or CLI acquisition
independently of its precedence `Layer`. Host file paths remain canonical;
filesystem paths are slash-relative within their namespace; synthetic inputs have
no path. Mutating
the returned configuration or inspection records cannot alter ConfigKit's private the returned configuration or inspection records cannot alter ConfigKit's private
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
frozen redacted projection. frozen redacted projection.
### Completion and resource lifetime
`Load` is synchronous and complete on return. Success means input assembly,
defaulting, typed decoding, configured application resolution, secret lookup,
validation, and configuration/metadata snapshotting have completed. A reported
load failure returns diagnostics and a nil result, as described above.
Before returning on success or failure, ConfigKit closes the filesystem handles
it opened for loading, including files and directory/root handles. Caller-owned
filesystems are borrowed, never closed. The returned
configuration and inspection metadata require no close operation and remain
usable independently of the source files. A supplied `Reader` remains
caller-owned and is never closed by ConfigKit.
ConfigKit performs no background work after returning: it does not watch inputs,
refresh secrets, or mutate published results. Inspection and rendering run only
when explicitly called and use the retained snapshot. Applications normally load
once near startup, but may explicitly load again using sources that satisfy their
documented reuse rules. Additional loads produce independently owned results.
Completion is not a transaction over external state. Files and environment
variables are observed during processing rather than as one simultaneous external
snapshot. ConfigKit does not roll back application callback effects or manage work
or resources created by application callbacks. The explicit secrets-directory
bootstrap remains a separate operation invoked by the application.
## Layered loading
`LoadLayered[T](main, options...)` accepts zero or one `LayeredOptions[T]` value.
Its `main` is an `Input` containing a source, import opt-in, and optional
filesystem context. A zero source is invalid; `Input{Source: NoSource()}` omits
the main document. Each `Underlay` contains inputs that compose strictly as
one tier, including each input's enabled imports. Empty groups contribute
nothing. Underlay groups are listed from lowest to highest priority. Repeating
a file within one group remains an import/repetition error; the same reusable
source may be selected again in another tier.
ConfigKit acquires and composes the main input first, constructs the schema,
and checks the optional application version from the main tier only. Underlay
roots cannot claim that version path (`underlay.version`); imported fragments
retain `import.version` protection. It then acquires and composes underlay
groups in order, recursively combines the groups, and applies the main tier,
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
definitions replace lower values between tiers; mappings combine recursively.
Syntax and strict-composition errors inside a tier cannot be masked by a
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
validation, and publication run once on the final assembled input.
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
independent of the main source's namespace. Its name and root-relative lookup
follow the selected-overlay rules below; inactive overlays ignore the context.
The same path in a different filesystem namespace is not a repeated file.
Ordinary `Load` continues to infer its selected-overlay context from its source.
Source IDs follow acquisition order—main and its depth-first imports, then each
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
history follows precedence order. Source `Layer` describes precedence while
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
one-based `UnderlayIndex` giving their original tier position; all other records
have index zero. Explicit empty groups and `NoSource` consume no source ID.
Byte, source, node, and glob-entry budgets are shared across the load, while
the existing per-source and import-depth bounds still apply.
When a source mapping is explicitly placed as an item, its effective destination
is the sequence container. After assembly assigns an index, nested source
destinations are projected through that item. Physical source locations and
resource contexts continue to identify the declaring source.
#### Input destinations
`Input.Destination` places the source's application mapping beneath a path
relative to the configuration root. Its zero or empty value means root, so
existing inputs keep their behavior. Main inputs and underlay inputs use the
same destination model. Destination segments are literal mapping keys: dots,
slashes, and numeric-looking text are not split or interpreted as sequence
indexes. Explicit index segments and paths that introduce the reserved
`configkit` or mapping-valued `extends` structure are invalid input options and
produce a structured `LoadError` before source acquisition.
Source-root `configkit.imports` directives are processed before placement. An
import without an explicit destination inherits the effective destination of
its importing document, including through nested imports. An import may instead
declare a relative destination as a sequence of literal mapping keys; its keys
append to the importing document's effective destination. Omission and `[]`
add no keys. Nested imports continue relative to their immediate importing
document, each declaration is independent of its siblings, and all matches of
one glob receive the same resulting destination. This logical placement does
not change how the physical import path resolves from its declaring file.
A valid destination on `NoSource()` contributes nothing; an actual empty
mapping materializes its destination containers. Once placed, contributions
follow the same strict composition within a tier and recursive precedence
behavior described above. A selected overlay and CLI overrides remain rooted at
the application root and are not moved beneath the main input's destination.
`SourceInfo.Destination` reports each acquired source's effective mapping or
sequence-container path, including imports. `PlacementMode` reports how that
source was placed; mapping is the zero value, and CLI/overlay sources report
mapping unless item placement was applied. `DestinationUnresolved` is true only
when an enclosing item index needed to name the source's destination has not yet
been assigned. In that case `Destination` reports the nearest resolved enclosing
collection container and omits the unresolved local suffix. A source's own item
placement still has an exact destination at its sequence container. Completed
loads project nested source destinations through their owning items. Callback and
failed-load snapshots preserve the evidence available at that point; a fallback
snapshot remains unresolved, while a later exact snapshot is false. Returned
source records and paths are detached. Placement does not change source IDs,
acquisition identity, physical source locations, or passive resource contexts.
Introduced ancestor mappings use the contributing document's root location as a
source fallback; authored content retains its original source coordinates.
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
and explicit resource access without a second configuration pass.
The accepted rationale for input placement and its relative-import extension
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
[ADR 0012](adr/0012-relative-import-destinations.md).
### Sibling inheritance
Any input mapping may declare `extends: name` to inherit from the mapping named
`name` among its immediate siblings. Names are exact keys, including the empty
string; lookup does not search ancestor or unrelated scopes. The document root
and a sequence-element mapping have no sibling namespace for their own string
relationship, though mappings nested inside them can have one. An `extends`
value of null cancels a relationship without looking up a parent. Other value
types, missing or non-mapping parents, and cycles fail the load with value-free
diagnostics.
`extends` is reserved structural input across ordinary and layered loads, every
source kind, imports, selected overlays, and explicit CLI overrides. It is
consumed before typed decoding and need not appear in the application's Go
types. Applications previously using `extends` as an ordinary YAML field must
rename that key and its field. No collection registration or keyword setup is
required.
ConfigKit completes strict composition within tiers and precedence assembly
between tiers before resolving relationships. An omitted `extends` retains a
lower-priority relationship; `extends: null` cancels it while preserving the
child's own settings. Superseded references are historical evidence, not active
relationships to validate. Each child recursively overlays its own settings
over its resolved parent: explicit zero, false, empty string, sequence, and
null values replace inherited values, while mappings combine recursively.
The child retains its key. All effective relationships resolve, including
entries the application may never select for execution. Partial parent
definitions undergo normal typed decoding and application-owned validation,
not a special structural exemption.
Enclosing relationships resolve first. Nested instructions carried by a parent
are then resolved separately at each destination, so a child's nested sibling
override can change an inherited nested relationship. Typed defaults and
application-derived values do not start another inheritance pass. Inherited
input counts as explicit presence for `Metadata.IsExplicit`,
`Resolution.IsExplicit`, and absence-based resolver defaults, even for false,
zero, empty, or null input. `Origin.Inheritance` distinguishes it from a direct
declaration with concrete source, destination, and controlling directive paths.
A direct child replacement has its own effective origin; displaced ancestry
remains inspectable in history. An inherited relative resource keeps the
declaring source's passive `SourceContext`, not the child's directory.
The optional schema-version checker still runs once against the main input at
its original point. Inheritance cannot add, replace, or consume the protected
version path; an unrelated relationship inside an enclosing mapping does not
invalidate an unchanged version. Expansion uses the decoded projection
node/depth limits without charging copied values as acquired sources;
retained evidence uses the existing history-node limit. See the
[consumer example](consumers/README.md#named-sibling-inheritance) for one-load
usage.
### Source resource contexts
`Result.SourceContext(id)` and callback-scoped `Resolution.SourceContext(id)`
return a passive `ResourceContext` for a source with filesystem context, or
false for unknown and contextless IDs. The result query is nil-safe; the
resolution query follows the callback's lifetime guard. Queries perform no
filesystem access. Obtain IDs from exact origin locations rather than guessing
one context for a composite origin or an application-derived value.
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
is its slash-relative directory inside that root. This includes imported
sources, selected overlays, host files even without enabled imports, native
files, and byte, reader, or mapping inputs with an explicit context. CLI and
contextless synthetic inputs have no resource context. To open a relative
resource, join `BaseDir` with the application resource name using slash-path
operations and open that path through `FS`.
For a resource-valued field, query `result.Metadata().Lookup(fieldPath)`, inspect
its origin locations, choose a source ID only when the application's policy can
identify the intended contributor, then call `result.SourceContext(id)`. The
context belongs to that source even when the field survived from a lower-priority
underlay. A composite origin has no automatically selected resource base.
Native contexts borrow the caller's filesystem within the configured root.
Host contexts retain a canonical root path, not an open root handle; each
resource `Open` checks containment and opens a fresh handle that the returned
file's `Close` releases. Applications own and close the files they open.
Resources are application data, not configuration input, and do not consume
configuration byte or node budgets. The loaded configuration and metadata stay
usable if source files later change or disappear. Later resource operations
observe the backing filesystem then and may fail if files move, disappear, or
a caller-owned filesystem closes. This is not a snapshot or universal sandbox.
### Application schema versions ### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed `VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
@@ -103,13 +325,75 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
diagnostic and suppresses the returned error text. Warnings alone are retained on a diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures. successful result. Callback panics remain programming failures.
#### Shared source-version checks
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
one `Definition.Version` policy applies across source documents. The zero value
`SourceVersionModeDefault` preserves the existing main-only check at its
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
checks each participating document that declares a version and permits absent
non-main declarations. `SourceVersionModeRequired` rejects a participating
document without a declaration. Enabled modes require a usable `VersionRule`
and checker; an unknown mode or unusable rule is an options error before source
access or callbacks.
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
document's own coordinates, before placement and composition. The main document,
direct underlay documents, imported documents (including transitive and glob
matches), and the selected overlay participate. `NoSource`, absent optional
imports, empty underlay groups, defaults, resolution output, inheritance, and
synthetic CLI overrides are not documents to check. An absent declaration in an
actual main document still reaches the checker in if-present mode; required mode
rejects it first. With `NoSource`, the existing single absent-main checker
behavior remains in both enabled modes. If-present omissions in non-main
documents skip the checker. Explicit null is present and reaches the checker;
unsupported selected values produce `version.type`. No equality with the main
declaration or non-null rule is imposed. Acceptance establishes only that the
declaration is accepted by the consumer's checker; it does not establish that
each fragment conforms to a complete application schema. Final assembled
decoding and application validation still run once.
A header that is absent below a structural control follows the same omission
policy. Actual header/control overlap is rejected, and ordinary structural
validation and main-version ownership protection continue independently.
Accepted non-main declarations are consumed from their owned payload before
composition; their authored ancestors remain, even if empty. The main
declaration remains in the application. Ordinary release metadata should use a
different path. Enabled main protection uses the main input's effective path:
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
the remaining payload against imports, underlays, inheritance, and CLI
overrides. Thus a mounted main looks up a local header while ownership checks
protect its destination-prefixed application path. Main checker diagnostics and
declaration locations use source-local coordinates; ordinary payload
diagnostics continue to use effective paths. Default-mode diagnostics are
unchanged.
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
occur. Otherwise it records a source-local `Path`, the observed `Version` state
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
available `Location`, and whether text was redacted. A non-nil record with
`Present == false` records observed absence; it does not say the checker ran or
accepted a declaration. Absent evidence still identifies the full requested
header path, while its location identifies the nearest available source node.
During callbacks and in failed-load source records,
text is conservatively withheld because final classification may not be
available. Successful final source records reflect completed sensitivity
classification at the declaration's mounted path and ancestors, including
historical classification. Redaction empties `Text` while retaining presence
and null state. Returned copies are detached; evidence does not reopen inputs.
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
decision and the [development guide](development.md#baseline-validation) for
the completed implementation and remediation records in Git history.
### Layered metadata ### Layered metadata
`Result.Metadata` describes every final canonical path. Queries retain explicit `Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes; key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never only parsed numeric tokens are resolved contextually. A descendant that never
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths existed is not historical merely because an atomic ancestor was replaced. Exact
are explicit and have a base or overlay origin; omitted paths have a default origin. surviving direct or inherited input paths are explicit and retain their source
layer and derivation; omitted paths have a default origin.
An explicit parent mapping does not make omitted children explicit. Null state and An explicit parent mapping does not make omitted children explicit. Null state and
available source coordinates are retained. `History` records the Go-zero or application available source coordinates are retained. `History` records the Go-zero or application
default baseline followed by exact base or overlay definitions, including default baseline followed by exact base or overlay definitions, including
@@ -124,13 +408,19 @@ domain ownership rules remain application responsibilities. The method's Go
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
details. details.
History is chronological: a path can have a synthetic default record, its exact base History is chronological: a path can have a synthetic default record, its exact
definition, an overlay replacement, and a recorded resolution operation. A descendant base definition, an overlay replacement, structural inheritance, and a recorded
resolution operation. A descendant
query uses the ancestor's path for a whole-container or whole-sequence replacement; query uses the ancestor's path for a whole-container or whole-sequence replacement;
the descendant can therefore retain history after `Lookup` stops reporting it as the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived effective. Equal-valued input is still an explicit base definition, while a derived
value for absent input remains non-explicit. value for absent input remains non-explicit.
Appending registered records during strict composition assigns each contribution
its assembled sequence index in history. A later precedence tier that supplies a
sequence records replacement at the sequence path; history does not imply
identity-based merging across tiers.
A mapping assembled from the selected file and imports has one composite base origin A mapping assembled from the selected file and imports has one composite base origin
whose locations retain source traversal order. A mapping also visited by an overlay whose locations retain source traversal order. A mapping also visited by an overlay
has an overlay-layer composite origin with the lower contributing locations followed has an overlay-layer composite origin with the lower contributing locations followed
@@ -141,6 +431,15 @@ operation. Reasons and dependency paths retain first occurrence order with dupli
removed. Source records remain ordered as selected source, depth-first imports, the removed. Source records remain ordered as selected source, depth-first imports, the
selected overlay, then synthetic CLI records in supplied order. selected overlay, then synthetic CLI records in supplied order.
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
lists concrete transfers from contributor paths through destinations, with
each step's controlling `extends` path and location. `History` retains source
applications at their original paths and projects parent contributions, child
replacements, and consumed controls at destination paths after source assembly
and before typed decoding. `HistoricalPaths` includes consumed directives and
displaced inherited descendants. A control can have history even when `Lookup`
reports it absent. Queries return detached records.
### Secret classification and effective rendering ### Secret classification and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional Secret classification combines `configkit:"secret"` field tags with the optional
@@ -156,6 +455,11 @@ Propagation reaches a fixed point, so record/map iteration order does not affect
result and dependency cycles terminate. A dependency on a classified descendant or result and dependency cycles terminate. A dependency on a classified descendant or
on a container that contains one is treated conservatively as sensitive. Applications on a container that contains one is treated conservatively as sensitive. Applications
must classify secret-producing transformations whose dependencies are not recorded. must classify secret-producing transformations whose dependencies are not recorded.
Inherited value and control paths link source and destination classifications
through chains and nested copies; unrelated sibling fields are not classified
merely because another field is secret. Displaced historical evidence remains
redacted. Secret references are copied only as unresolved names; credential
lookup stays after decoding and application resolution.
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted `Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
@@ -190,7 +494,10 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
null is present and renders as `null`. The history starts with the canonical typed null is present and renders as `null`. The history starts with the canonical typed
application-default or Go-zero baseline, then includes only relevant base sources in application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected overlay, supplied CLI records, and audited net traversal order, the selected overlay, supplied CLI records, and audited net
resolution operations. Overlapping parent/child resolution records are rendered resolution operations. Structural inheritance events appear after source replay
and before typed reconciliation, including consumed or canceled `extends` input
and equal-valued child replacements. Overlapping parent/child resolution records
are rendered
as one net subtree event with combined reasons and dependencies, rather than as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay writes remain visible. When input replay
@@ -238,6 +545,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
ConfigKit does not infer bootstrap outcomes from a secret reference and does not ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework. bundle these independent reports into a command framework.
## Effective configuration diffs
`configkit.Diff(left, right)` compares two completed successful loads of the same
configuration type. `left` is the earlier state and `right` is the later state.
Applications load both sides with the sources and options they choose; to inspect
an overlay's impact, load the same base once without the overlay and once with it
selected. The [consumer guide](consumers/README.md#effective-configuration-diffs)
links to maintained examples. The public API returns safe changed-region records;
applications own their presentation.
Comparison uses the frozen canonical effective inspection values, not mutable
`Result.Config`, source text, provenance, or history. It does not reopen sources,
reread environment variables, or rerun defaults or callbacks. Equal effective
values produce no records even when sources, explicit-input status, defaults, or
resolution histories differ; for example, explicit zero and defaulted zero are
equal. Mappings compare recursively, sequences compare atomically, and scalar,
kind, or null/container replacements report their affected region. Records use
the existing comparator's canonical pointer ordering. `Present=false` with empty
YAML means absence; explicit null is present and renders as `null`.
Before either side is returned, sensitivity from both loads is applied to both
snapshots, including historical and dependency-derived classification. A
classification on either side protects the corresponding values on both sides.
`Redacted` reports masking, while `HiddenChanged` reports an actual concealed
value or presence change; it is not implied merely by redaction. A structural
replacement that prevents safe correspondence masks the complete reported
region, and an absent side remains absent. Record membership establishes that a
change occurred even when safe snapshots render identically. Literal
marker-looking input is ordinary input; consumers use the record fields rather
than interpreting marker text.
Typed secret references contribute their configured environment names to the
frozen comparison, never resolved credential bytes. Credential rotation behind
an unchanged reference is therefore not detected. The records contain changed
regions only, not paired full-document context; independently calling
`WriteEffective` on each result does not create cross-protected context. Nil or
uninitialized results return an error with no records. A failed `Load` retains
its normal nil-result/`LoadError` behavior, which callers handle before invoking
`Diff`.
## Typed secret references ## Typed secret references
Credential-bearing fields use the opaque `Secret` type and store an environment Credential-bearing fields use the opaque `Secret` type and store an environment
@@ -421,12 +768,47 @@ Application defaults do not participate in this operation.
| Mapping | Mapping | Recurse by mapping key | | Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` | | Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
Every non-mapping overlap is a conflict, even when values are equal. Nulls and Every non-mapping overlap is a conflict, even when values are equal, except for
sequences are present atomic values, so they also conflict when repeated; sequences sequence/sequence contributions at a registered sequence-composition path. Nulls
are never joined by index. Disjoint entries under shared mappings compose, making remain atomic conflicts. Unregistered sequences conflict when repeated and are
never joined by index. Disjoint entries under shared mappings compose, making
stable keyed mappings the natural representation for independently contributed stable keyed mappings the natural representation for independently contributed
entries. entries.
`Definition.SequenceCompositions` registers literal mapping paths and one literal
`IdentityField` for sequences that should compose independently authored records.
Registration paths follow input-destination key rules: parsed numeric tokens are
literal mapping keys, explicit index segments and reserved `configkit` or
mapping-valued `extends` segments are invalid, and the root registration is
harmless and unused. The identity field is one literal string key; neither the
field name nor identity strings are normalized.
At each registered path, every actual sequence contribution in a strict tier is
validated, including a singleton contribution: each record must be a mapping with
a present string identity field. Distinct exact identity strings append in source
and list order; duplicate identities conflict even if the records are otherwise
equal or disjoint. Empty identity strings are valid. Malformed records produce
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
sequence container and source locations identifying a duplicate without exposing
identity values. Missing paths and non-sequence values do not fail by themselves.
Identical registrations coalesce; conflicting registrations at one path and
invalid registration paths produce `options.invalid`. Unused and root registrations
are allowed and have no effect.
Registration changes strict composition only. Each underlay group and the main
tier has independent duplicate-identity state. Precedence assembly is unchanged:
a present higher-priority sequence replaces the lower sequence as one value,
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
and application resolution do not run the registered identity check. The accepted
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows item path and glob imports, helper-supplied identity, and main-tier
replacement of a lower-priority sequence.
When an enabled source-version mode is selected, declarations are checked in each
source's own coordinates before placement and composition. Accepted non-main
headers are consumed before strict composition and cannot supply a registered
record identity. Final typed decoding and application validation still run on the
completed configuration.
An empty mapping contributes no child definitions. Two empty mappings compose, and An empty mapping contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children. an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite Shared mapping containers retain all contributing value locations as their composite
@@ -442,8 +824,8 @@ input document.
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name `[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly. `default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
no extension fallback, case normalization, inheritance, or overlay stack. A selected no extension fallback, case normalization, inheritance between overlay files,
missing overlay is an error. or overlay stack. A selected missing overlay is an error.
The overlay is a separately policy-validated YAML mapping. It is loaded after the The overlay is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults base import graph is composed and its schema version is checked, but before defaults
@@ -526,9 +908,13 @@ zero.
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before `configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and string `path` or `glob`, an optional boolean `optional`, and an optional
fields are `import.invalid`. A `configkit` key below the root is invalid. With `destination` sequence of strings. Every destination string is one literal
imports disabled, any occurrence remains `import.disabled`. mapping key and follows the input-destination validation rules above; quote
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
and non-string members are `import.invalid`, as are all other unsupported shapes
and fields. A `configkit` key below the root is invalid. With imports disabled,
any occurrence remains `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0. The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to There is no legacy alias: `_configkit` is ordinary application data, subject to
@@ -557,18 +943,49 @@ walk failures, matched directories, invalid documents, escapes, and repeated tar
remain errors. Final candidates are bounded by the remaining source limit before remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated. their contents are accumulated.
The configuration root defaults to a selected file's canonical directory. A file Each path or glob declaration may include `placement: mapping` or
source may supply `FilesystemContext.Root` to widen that root but may not supply `placement: sequence-item`. Omission means `mapping` for that declaration,
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must including every match of a glob; placement is not inherited. Sequence-item
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic placement requires a nonempty effective destination and wraps the imported
labels, including URI-looking labels, never imply filesystem paths. The base and mapping as one element of a sequence there. It does not split source sequences
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs; or infer identity fields. Repeated sequences still conflict unless their path is
registered through `Definition.SequenceCompositions`; registration does not
change placement or precedence behavior. Sequence records retain source
locations through placement and history; indexes describe their assembled
positions while source coordinates continue to identify the declaring files.
Header/version policy and final application validation retain their existing
owners and timing; sequence registration is a strict-composition rule only.
An item import first opens a local mapping scope. Its body and ordinary helper
imports compose within that record. An omitted helper destination or
`destination: []` places the helper at the record root; other destinations are
relative to the immediate importing source. A helper may supply fields such as
the record's `id`. An explicit nested `sequence-item` import creates a sequence
inside the record, and its placement is independent of the enclosing item.
At the record root, an item import must name a nonempty nested destination.
Physical paths continue to resolve from the file that declares each import.
An actual empty mapping contributes an empty item, while an optional unmatched
path or glob contributes nothing.
For host files, the configuration root defaults to the selected file's canonical
directory. A host file may supply `FilesystemContext.Root` to widen that root but
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
must provide both `BaseDir` and `Root` as absolute existing directories.
For `Filesystem.File`, the default root is `.` in its namespace and the base is
the selected file's directory. An explicit context must use the same `Filesystem`
namespace and may set `Root` to contain the file and its base; it may not supply
`BaseDir`. Byte, reader, and mapping inputs with native context provide both `BaseDir` and
`Root` as slash-relative `fs.ValidPath` directories in `FS` (`.` means its root),
with the base inside the root. Context paths are checked even when imports and
overlays are inactive. Diagnostic labels, including URI-looking labels, never
imply filesystem paths. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
post-open regular-file checks remain in place under the stable-input assumption. post-open regular-file checks remain in place under the stable-input assumption.
ConfigKit canonicalizes symlinks, ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at are accepted; escapes are rejected. Canonical paths define identity, so hard links at
distinct paths remain distinct sources. distinct paths remain distinct sources. Native filesystems use slash-path operations
and their own symlink behavior; imports stay in their selected namespace and root.
An active-stack repeat is `import.cycle` and reports the complete cycle's import An active-stack repeat is `import.cycle` and reports the complete cycle's import
sites. A previously visited canonical path is `import.repeated` and reports both sites. A previously visited canonical path is `import.repeated` and reports both

View File

@@ -7,7 +7,7 @@ validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization. resource checks, remote acquisition, and service initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
four sanitized shapes using only ConfigKit and the Go standard library: sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained | | Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- | | --- | --- | --- |
@@ -15,6 +15,13 @@ four sanitized shapes using only ConfigKit and the Go standard library:
| 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` | | 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 | | 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 | | 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 |
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data, executable examples and ordinary tests. It contains no private application data,
@@ -48,6 +55,133 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts. execution profile concepts.
## Shared source-version checks
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption,
main-path protection, default behavior, and evidence disclosure. Accepted
declarations do not replace final assembled decoding or application validation.
## Effective configuration diffs
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
with one application definition, handles each load error, calls `configkit.Diff`,
and presents the safe records through consumer-owned `FormatDifferences`.
[`CompareRolloutOverlay`](../../examples/consumers/diff.go) loads the same base
once without an overlay and once with the selected overlay, so omitted lower
priority values remain present. Its resolver records a derived endpoint URL after
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.
## Named sibling inheritance
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
[`presets.yml`](../../examples/consumers/testdata/inheritance/presets.yml) in one
call. `custom` inherits `enabled: false` from `base`, while its nested
`settings.primary` changes the inherited `settings.secondary` result at the
custom destination. The application struct has no `extends` field; ConfigKit
consumes the relationship before strict typed decoding.
The [executable example](../../examples/consumers/consumers_test.go) reads
`Metadata.IsExplicit` for the inherited false value, then follows
`Origin.Inheritance` back to the parent path while `Origin.Locations` identifies
the declaring source. Applications still choose which preset to execute and
validate domain meaning. The [configuration contract](../config.md#sibling-inheritance)
owns exact lookup, precedence, cancellation, and migration rules.
## Native filesystem and layered input
[`LoadBundled`](../../examples/consumers/layered.go) keeps the simple `Load` shape:
it selects a file in an application-owned embedded filesystem and needs no
underlay setup. [`LoadCatalog`](../../examples/consumers/layered.go) uses
`LoadLayered` with an imported embedded-defaults group, a separate
`Mapping` group, and a host main file supplied by its caller. The groups
compose strictly within themselves and recursively across precedence boundaries;
the main file's explicit `false` and `0` replace lower values without being
mistaken for omissions. Its validator owns allowed modes and resource-name policy.
[`ReadWelcome`](../../examples/consumers/layered.go) follows the effective field's
origin location to a source ID, queries `Result.SourceContext`, and opens the
resource relative to that context's `BaseDir`. It does not assume the main file
owns a retained underlay value. A composite origin needs an application choice;
ConfigKit does not guess a directory. The context query is passive, while later
resource reads observe the caller-owned filesystem or a freshly opened host root
and may fail independently of the completed configuration load. The
[configuration contract](../config.md#source-resource-contexts) owns exact
lifetime and precedence rules.
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
counterpart: its entry opts into imports through `Load`, imports metadata at the
root, and places a prompt beneath `prompts` using YAML. The executable example
and behavior test are in
[`layered_test.go`](../../examples/consumers/layered_test.go).
## Input destinations and mounted collections
Main and underlay inputs use the same destination field. An import without a
`destination` inherits its declaring document's effective destination; an
explicit sequence of key strings appends a relative suffix. Nested imports
append to their immediate parent's effective destination, while filesystem
paths remain relative to the declaring file. Destinations are application
mapping paths, independent of filesystem context. The configuration contract
owns the complete behavior and validation rules.
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
mounts one mapping source beneath literal collection/prompt keys and loads a
second input without a destination to show that root placement remains the
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
to interpret.
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
embedded collection manifest mounted beneath a literal collection path. The
entry's YAML places nested metadata imports at the collection root and prompt
and profile peers beneath their own keys. A higher-priority input changes only
one prompt's ordinary `version` field. The main document alone supplies the
application schema version. A prompt's local `extends` relationship resolves
after all tiers, so `custom` inherits the final `brief` content and fields while
retaining its own profile selection.
The resolver follows each effective `content_file` origin to its declaring
source context. This also works for `custom`'s inherited resource. The
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
demonstrates the same lookup after loading; ConfigKit does not open or interpret
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
checks source destinations and authored coordinates, presence, inherited values,
relative resources, and strict peer conflicts. The
[configuration contract](../config.md#input-destinations) owns shared placement,
import, source-location, and ownership behavior.
## Identified prompt sequences
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
definition registration for the literal `prompts` path and `id` field. Its main
file imports one prompt by path and others by glob, placing each source as an
item. A nested ordinary helper import supplies an item's ID. The main tier
strictly appends the distinct records in contribution order; its completed
sequence replaces the lower-priority default collection as one value.
The example's `validatePromptCatalog` remains application-owned. It checks that
prompt names and message lists satisfy this consumer's requirements after
ConfigKit has composed, projected, and decoded the configuration. It does not
add recursive record merging, identity-based overlay, or final uniqueness checks
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
[import syntax](../config.md#explicit-imports), and
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
boundaries.
Known module catalogs should use canonical object entries with a module identifier Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime- fields are known; validation selects which fields apply to each module. Runtime-
@@ -126,6 +260,10 @@ focused behavioral owner:
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture | | Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | | `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests | | Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
The package-level import-boundary test covers the consumer examples too: supported The package-level import-boundary test covers the consumer examples too: supported

View File

@@ -8,32 +8,57 @@ public behavior.
## Initial orientation ## Initial orientation
1. Inspect the working tree and preserve unrelated changes. 1. Inspect the working tree and preserve unrelated changes.
Agents must read and follow the [agent policy](policy/agents.md) before 2. Use the Policy Reading Guide below to select applicable policies.
recommending, planning, or implementing changes. 3. Read the contract and internal documents applicable to the task.
2. Read the architecture policy for code or design work.
3. Read the policy, contract, and internal documents applicable to the task.
4. Inspect the relevant implementation and behavioral tests before changing them. 4. Inspect the relevant implementation and behavioral tests before changing them.
## Policy Reading Guide
| Policy | Read before… |
| --- | --- |
| [Policy set provenance](policy/meta.md) | Adopting policies, checking upstream revisions, or updating the adopted policy set. |
| [Agents](policy/agents.md) | Recommending, planning, or implementing repository changes. |
| [Architecture](policy/architecture.md) | Planning or changing Go library code or its structure. |
| [Testing](policy/testing.md) | Selecting validation for code changes, or adding, changing, removing, or evaluating tests. |
| [Documentation](policy/documentation.md) | Writing, revising, or reorganizing documentation. |
| [OSS](policy/oss.md) | Adding or updating dependencies or incorporated third-party material, or changing licensing or attribution. |
| [Release](policy/release.md) | Preparing or publishing a release, changing release procedures, or making versioning and compatibility decisions. |
- Read applicable policies before making decisions they govern; multiple policies
may apply.
- Select policies using the task triggers above. Follow cross-references to
relevant sections when needed to resolve a concrete question.
- Reuse policies already available in context; reread when changed or no longer
available.
- Expand reading when the task's scope expands.
- When revising a policy itself, read that policy and any provisions elsewhere
affected by the revision.
## Task-specific reading guide ## Task-specific reading guide
| Task | Read | | Task | Read |
| --- | --- | | --- | --- |
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) | | Repository orientation or component responsibility | [Component overview](internal/overview.md) |
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document | | Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
| Licensing, dependencies, attribution, or release-document changes | [OSS policy](policy/oss.md), [project grant](../COPYRIGHT), and [attribution](oss/attribution.md); release changes also follow the release policy and procedure | | Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below | | Documentation changes | The owning document |
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below | | Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
| Code and test changes | The owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) | | Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) | | Release preparation | [Release procedure](release.md) |
Accepted decisions: Accepted decisions:
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md) - [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md) - [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md) - [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md) - [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md) - [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md) - [Keep the public facade root-owned](adr/0006-public-facade.md)
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
For cross-cutting changes, follow every applicable row. Completed implementation For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -44,7 +69,9 @@ git show cb5b435:docs/roadmap/remediation.md
``` ```
Release-candidate, publication, and verification work follows the operational Release-candidate, publication, and verification work follows the operational
[release procedure](release.md). [release procedure](release.md). Its module-selected ReleaseKit `publish`
invocation validates the committed candidate and publishes only the intended
tag; those release checks remain distinct from the everyday development check.
The completed string-and-integer schema-version implementation and remediation The completed string-and-integer schema-version implementation and remediation
records are preserved in Git history at `244104f`. The records are preserved in Git history at `244104f`. The
@@ -59,14 +86,73 @@ and current facade ownership belongs to the [component overview](internal/overvi
## Baseline validation ## Baseline validation
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
[configuration contract](config.md#layered-loading). Built-in sibling
inheritance is now part of the [configuration contract](config.md#sibling-inheritance).
The completed request, roadmap, plan, and resolved review findings are preserved
in Git history at `83c59ea` under `docs/roadmap/`.
The completed overlay rename plan and release-workflow simplification record are The completed overlay rename plan and release-workflow simplification record are
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
behavior belongs to the [configuration contract](config.md#selected-overlays), behavior belongs to the [configuration contract](config.md#selected-overlays),
and release operations belong to the [release procedure](release.md). and release operations belong to the [release procedure](release.md).
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared The completed ReleaseKit adoption, startup performance request and experiments,
dependencies explicitly before entering an offline development or validation benchmark evidence, performance audit, implementation plan, and remediation
environment: record are preserved in Git history at `8e91a2b` under `docs/roadmap/`. For example:
```sh
git show 8e91a2b:docs/roadmap/startup-benchmark.md
```
Remaining optimization candidates belong to
[future performance experiments](roadmap/future.md#3-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under
`docs/roadmap/`. The
[configuration contract](config.md#effective-configuration-diffs) owns current
comparison and disclosure behavior, and
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
The completed input-destination, relative-import-destination, and shared
source-version roadmaps, associated consumer request, implementation plan, and
resolved review findings are preserved in Git history at `e4270c9` under
`docs/roadmap/`. For example:
```sh
git show e4270c9:docs/roadmap/implementation.md
git show e4270c9:docs/roadmap/remediation.md
```
Current behavior belongs to the configuration contract's
[input destinations](config.md#input-destinations),
[imports](config.md#explicit-imports), and
[shared source-version checks](config.md#shared-source-version-checks) sections.
Explicit sequence-item placement and registered strict composition are owned by
the [strict composition](config.md#strict-document-composition) and
[imports](config.md#explicit-imports) sections. The
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
records the decision.
The completed sequence-composition roadmap, implementation plan, and resolved
remediation record are preserved in Git history at `008d2ba` under
`docs/roadmap/`. For example:
```sh
git show 008d2ba:docs/roadmap/sequence-composition.md
git show 008d2ba:docs/roadmap/implementation.md
git show 008d2ba:docs/roadmap/remediation.md
```
Potential enhancements are tracked in [future features](roadmap/future.md).
Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline
development or validation environment:
```sh ```sh
go mod download go mod download
@@ -96,10 +182,10 @@ Use focused checks while iterating, with validation proportionate to the change
under the testing policy. Documentation-only work normally needs the local-link under the testing policy. Documentation-only work normally needs the local-link
check below and whitespace review; behavior-bearing examples and commands also check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes. need execution. Use `go test -race ./...` for ownership or concurrency changes.
The [release checker](release.md#4-validate-the-source-candidate) performs one The [release procedure](release.md#4-publish-the-committed-candidate) performs
minimum-toolchain race-suite run, vet, module hygiene, and Linux cross-builds. its selected validation through one `publish` invocation. The everyday checker
It already covers examples, links, and package boundaries; do not additionally remains a development gate and is not a separate release prerequisite. CI never
run the everyday checker just to repeat those checks. CI never gates a release. gates a release.
## Task-to-test map ## Task-to-test map
@@ -110,15 +196,25 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command | | Task | Behavioral test owners | Focused command |
| --- | --- | --- | | --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | 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'` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` | | Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| 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'` |
| 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` |
| 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 | [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'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| 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'` | | 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'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | | 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` |
| 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'` | | 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'` |
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` | | 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 ## Fuzz validation

View File

@@ -7,23 +7,39 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners: Implemented component owners:
- `internal/engine` owns source acquisition, composition, schema inspection, - `internal/engine` owns host and native-filesystem source acquisition,
decoding, lifecycle coordination, callback auditing, provenance/history, grouped strict composition, recursive precedence assembly, per-input mapping
secret resolution and bootstrap, and safe inspection rendering. It never and sequence-item placement, definition-registered distinct-ID sequence
imports the root package. composition within strict tiers, and immediate-parent-relative import
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and placement within that assembly. Higher-priority sequences replace lower
bootstrap reports are root-defined wrappers or records. Callback wrappers pass sequences as whole values. The engine also owns destination-local sibling
only live engine context during a callback; `Result.Config` is detached and its inheritance before typed decoding, plus shared source-version checking with
private inspection remains frozen independently. sanitized declaration evidence.
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
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 - Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods. 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.
- `internal/document` owns the parser-independent ordered tree and cloning. - `internal/document` owns the parser-independent ordered tree and cloning.
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary. `internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
- Engine mechanism tests own source, composition, decoding, callback, provenance, - Engine mechanism tests own source, composition, inheritance, decoding,
secret, history, rendering, and bootstrap behavior. Root tests own public API callback, provenance,
compatibility, facade integration, documentation links, and dependency/public source-context, secret, history, rendering, and bootstrap behavior. Root tests
boundary checks. Consumer examples remain under `examples/consumers`. own public API compatibility, facade integration, documentation links, and
dependency/public boundary checks. Consumer examples remain under
`examples/consumers`.
Dependency direction is root to engine to document/YAML support. The root may Dependency direction is root to engine to document/YAML support. The root may
import only `internal/engine` among project internal production packages; engine import only `internal/engine` among project internal production packages; engine
@@ -32,8 +48,23 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types. internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics. The [consumer adoption guide](../consumers/README.md) owns application semantics, including [input destinations](../config.md#input-destinations),
guidance. The [architecture policy](../policy/architecture.md) owns contributor [shared source-version checks](../config.md#shared-source-version-checks), and
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade [effective configuration diffs](../config.md#effective-configuration-diffs).
The [strict composition contract](../config.md#strict-document-composition)
owns sequence placement, registration, replacement, and history behavior; the
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
maintained example.
The [consumer adoption guide](../consumers/README.md) owns application guidance.
The [architecture policy](../policy/architecture.md) owns contributor
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
decision. See the [development guide](../development.md) for validation commands decision. See the [development guide](../development.md) for validation commands
and [release procedure](../release.md) for source-candidate gates. and [release procedure](../release.md) for source-candidate gates. [ADR
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
records the source-version decision.
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
sequence placement and composition.

View File

@@ -1,11 +1,31 @@
# Open Source Attribution # Open Source Attribution Notices
Component notices follow the [OSS policy](../policy/oss.md). ConfigKit's own ## Standard Repository Policies
grant is in [COPYRIGHT](../../COPYRIGHT); dependency notices do not change it.
Material: `docs/policy/release.md`, adapted from
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
`docs/policy/documentation.md`, `docs/policy/agents.md`,
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
Policy Reading Guide in `docs/development.md`, adapted from
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: CC-BY-SA-4.0
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
## go-yaml v4 ## go-yaml v4
Version: `go.yaml.in/yaml/v4 v4.0.0-rc.6`. Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
Copyright 2011-2019 Canonical Ltd Copyright 2011-2019 Canonical Ltd
@@ -17,12 +37,7 @@ SPDX-License-Identifier: Apache-2.0 AND MIT
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) | License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[MIT](licenses/MIT.txt) | [MIT](licenses/MIT.txt) |
[upstream NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE) | [NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE) |
[upstream LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) [LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE).
Source: [go-yaml](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125). [Source](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
The upstream NOTICE identifies the libyaml-derived portion as MIT-licensed and
the remaining project files as Apache-2.0-licensed; these are not alternative
licenses for the entire module. The statements above preserve the NOTICE's
wording. The module declares no additional Go module requirements.

View File

@@ -0,0 +1,170 @@
Creative Commons Attribution-ShareAlike 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
Creative Commons Attribution-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 – Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 – Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
Section 3 – License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 – Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
Section 5 – Disclaimer of Warranties and Limitation of Liability.
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 – Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 – Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 – Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
Creative Commons may be contacted at creativecommons.org.

View File

@@ -1,12 +1,14 @@
# Agent Policy # Agent Policy
**Base Policy Version:** 1.1 **Base Policy Version:** 1.3
**Last Updated**: 2026-09-23 **Last Updated**: 2026-09-28
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/agents.md)
## Purpose ## Purpose
Agents working in this repository must read and adhere to the following house rules. Agents must read and follow these rules.
## Implementation Discipline ## Implementation Discipline
@@ -14,8 +16,6 @@ Agents working in this repository must read and adhere to the following house ru
request. Do not implicitly strengthen requirements, restrictions, or guarantees request. Do not implicitly strengthen requirements, restrictions, or guarantees
beyond what the user actually requested. beyond what the user actually requested.
Simplify the mechanism, not the user's choices.
**Keep implementation machinery proportionate.** Use straightforward, **Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and behavior and flexibility. Optimize for conceptual simplicity and
@@ -57,15 +57,15 @@ the ambiguity rather than treating repetition as approval.
## Git Commit Policy ## Git Commit Policy
By default, create a commit after completing each coherent unit of work, using a By default, commit each completed, nonempty unit of work using a
concise, plain-English message describing what changed. This policy is standing concise, plain-English message describing what changed. This policy is standing
authorization to create those local commits; a separate user request is not authorization to create those local commits; a separate user request is not
required. An explicit user instruction to leave work uncommitted takes precedence. required. An explicit user instruction to leave work uncommitted takes precedence.
A unit of work is a focused, reviewable outcome, including its relevant tests and A unit of work is a focused, reviewable outcome, including its relevant tests and
documentation. Do not commit after every intermediate edit or create empty commits. documentation.
Complete the appropriate validation before committing. If work is incomplete or Complete the appropriate validation before committing. If work is incomplete or
validation is blocked, report that state rather than presenting it as completed. validation is blocked, report that state accurately.
Before committing: Before committing:
@@ -73,10 +73,10 @@ Before committing:
changes. Identify changes that predate the current unit of work. changes. Identify changes that predate the current unit of work.
2. Exclude secrets, local environment files, generated junk, and unrelated changes. 2. Exclude secrets, local environment files, generated junk, and unrelated changes.
3. Stage only the files or hunks belonging to the completed unit. Use `git add .` 3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
only when every included change belongs to that unit. Do not disturb unrelated only when every included change belongs to that unit. Preserve unrelated
staged or unstaged work. staged and unstaged work.
4. Review `git diff --staged` and run appropriate checks for the change. Do not 4. Review `git diff --staged` and run appropriate checks for the change. Reuse
repeat checks already passed for unchanged content merely to create a commit. successful checks for unchanged content.
5. Review `git log --oneline -n 10` and use 5. Review `git log --oneline -n 10` and use
`git commit -m "<concise plain-English commit message>"`. `git commit -m "<concise plain-English commit message>"`.
6. Report the commit identifier and any remaining uncommitted work. 6. Report the commit identifier and any remaining uncommitted work.

View File

@@ -1,18 +1,55 @@
# Architecture Policy # Architecture Policy
**Base Policy Version:** 1.0 **Base Policy Version:** 1.3
**Last Updated:** 2026-09-06 **Last Updated**: 2026-10-16
## Purpose and applicability **Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
## Purpose And Ownership
The adopted policy defines the application's purpose and the architectural
constraints contributors must follow. It owns system boundaries, responsibility
and dependency rules, established invariants, and explicit non-goals.
Describe the concrete package inventory and implementation mechanics in internal
documentation. Link to canonical public contracts for their exact behavior and
to ADRs for decision rationale.
ConfigKit is an opinionated Go library for consistent YAML configuration loading ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. This policy governs contributions. and resolution across consuming applications. The [component overview](../internal/overview.md)
The [component overview](../internal/overview.md) owns implementation inventory, the owns implementation inventory, the [configuration contract](../config.md) owns
[configuration contract](../config.md) owns implemented behavior, and the implemented behavior, and the [future feature list](../roadmap/future.md) preserves
[deferred scope](../roadmap/deferred.md) preserves deferred scope. candidates for future consideration.
## Success criteria ## Adopted Constraints And Implemented Structure
Architectural constraints govern current and future development. Record
implemented structure accurately, proposals and implementation status in the
roadmap, and significant decisions and rationale in ADRs. Describe a target
structure as implemented when its changes land.
Constraints must reflect deliberately selected requirements and decisions.
Do not promote incidental implementation choices into architectural invariants.
## Success Criteria
ConfigKit prioritizes a coherent configuration model: consistency and
predictability take precedence over flexibility for accommodating individual
downstream conventions. Consumers should learn one set of rules, not different
combination or presence semantics depending on the source format or consuming
application. Distinct operations such as strict composition and intentional
overlay express distinct purposes within that model.
Consumers should be able to load once and receive a complete, validated
configuration with inspectable provenance for its final values, including
defaults and recorded application-derived values. Configuration and metadata
together must distinguish explicit zero, false, empty, and null input from
omission; ordinary Go values alone need not encode those distinctions.
Application-owned resolution belongs within the recorded loading lifecycle, so
consumers need no subsequent generic merging, defaulting, or provenance
reconstruction. This does not transfer domain execution or runtime resource
management into ConfigKit.
1. **Reduce application infrastructure:** Can applications delete substantial 1. **Reduce application infrastructure:** Can applications delete substantial
configuration infrastructure while retaining clear ownership of their domain configuration infrastructure while retaining clear ownership of their domain
@@ -21,8 +58,11 @@ The [component overview](../internal/overview.md) owns implementation inventory,
configuration model, with only the flexibility needed to express genuine configuration model, with only the flexibility needed to express genuine
application requirements? application requirements?
Both criteria must hold. ConfigKit should not preserve divergent consumer Both criteria must hold. Minimize consumer loading and processing code subject
conventions merely for compatibility, nor impose restrictions that prevent useful to the consistency, presence, and provenance principles above, rather than
trading those principles for consumer-specific shortcuts. ConfigKit should not
preserve divergent consumer conventions merely for compatibility, nor impose
restrictions that prevent useful
domain information from being expressed. Prefer a canonical representation using domain information from being expressed. Prefer a canonical representation using
existing primitives before adding another mechanism. Extend the model when a existing primitives before adding another mechanism. Extend the model when a
concrete requirement cannot be accommodated cleanly, not simply because a concrete requirement cannot be accommodated cleanly, not simply because a
@@ -51,7 +91,7 @@ boundaries, and keep simple consumers independent of optional composition
features. The [consumer adoption guide](../consumers/README.md) demonstrates the features. The [consumer adoption guide](../consumers/README.md) demonstrates the
implemented surface against representative simple and complex shapes. implemented surface against representative simple and complex shapes.
## Responsibility boundaries ## Responsibility Boundaries
ConfigKit owns configuration input processing and the coordination of decoding, ConfigKit owns configuration input processing and the coordination of decoding,
resolution, validation, and inspection. Applications own their configuration resolution, validation, and inspection. Applications own their configuration
@@ -59,6 +99,15 @@ types, field meanings, defaults, normalization, semantic validation, and schema
version policy. Applications also own main-file search conventions, CLI wiring, version policy. Applications also own main-file search conventions, CLI wiring,
remote acquisition, runtime initialization, and resource checks. remote acquisition, runtime initialization, and resource checks.
The engine applies each input's mapping destination within the existing source
assembly, applies immediate-parent-relative YAML destinations to imports, and
retains each effective placement with source identity and inspection records;
the root facade owns the public fields and conversions. The
[configuration contract](../config.md#input-destinations) defines the observable
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
the import extension.
Library code must not depend on consuming applications or initialize their Library code must not depend on consuming applications or initialize their
runtime resources. It must not perform implicit network access, discover runtime resources. It must not perform implicit network access, discover
application-specific paths, or change process-global state during ordinary `Load`. application-specific paths, or change process-global state during ordinary `Load`.
@@ -68,7 +117,23 @@ must never run implicitly as part of configuration loading.
Remote data can enter through application-supplied input without introducing a Remote data can enter through application-supplied input without introducing a
network provider into the library. network provider into the library.
## Public API and dependency direction ## Loading Lifecycle
Configuration loading is a synchronous, complete-on-return operation. A successful
load provides the final configuration and inspection metadata after the configured
processing and validation phases complete. A failed load reports diagnostics and
returns no usable partial result. The normal application pattern is one load near
startup; applications may explicitly initiate additional loads.
ConfigKit releases its library-owned loading handles before returning and remains
inactive until the application explicitly invokes it again. It performs no
background watching, refresh, or mutation of published results. Caller-owned
resources and application callback effects remain the caller's responsibility.
The [configuration contract](../config.md#completion-and-resource-lifetime) owns
the precise completion and ownership behavior. The proposed decision record is
[ADR 0008](../adr/0008-complete-on-return-loading.md).
## Public API And Dependency Direction
Keep one small, coherent public surface for the supported lifecycle. Simple Keep one small, coherent public surface for the supported lifecycle. Simple
single-document consumers must not need import, overlay, or provider machinery. single-document consumers must not need import, overlay, or provider machinery.
@@ -94,20 +159,53 @@ Dependencies must preserve those boundaries; package splits and abstractions
must earn their complexity. Do not impose a full application or hexagonal must earn their complexity. Do not impose a full application or hexagonal
framework on this library. framework on this library.
## Configuration integrity ## Configuration Integrity
Composition and intentional overlay are distinct operations. Composition must Within a configuration tier, independent contributions use strict composition:
not silently become last-wins override through file ordering. The decision and shared mappings combine recursively, while overlapping non-mapping definitions
alternatives are recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md). are conflicts, including equal values, except that sequences at explicitly
registered paths append records with distinct present string identities. Between
precedence tiers, intentional overlay combines mappings recursively, retains
omitted lower-priority children,
and replaces other present values according to the existing overlay rules.
Tier precedence is explicit; traversal order within a tier establishes stable
reporting, not permission to override. The distinction and its rationale are
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
Sequence-item placement and sequence registration are explicit source and
definition controls; registration does not change precedence replacement. The
[configuration contract](../config.md#strict-document-composition) defines the
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
shows the implemented subset. This feature does not authorize arbitrary
configurable merge strategies.
The main document and its imports form a strictly composed tier. Explicit
underlay groups compose strictly within each group, then combine recursively
from lowest to highest beneath the main tier. Source acquisition kind does not
determine its precedence role. Input-specific constraints, including CLI path
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
semantics do not require identical acquisition or lifetime behavior from every
source kind.
After source precedence is complete, built-in sibling `extends` relationships
resolve structurally in the same parser-independent representation, using the
existing recursive overlay rules. This adds neither an acquired source nor a
consumer registration mechanism. Enclosing relationships precede nested
destination-local resolution; typed defaults, application resolution, and
secret lookup retain their existing lifecycle roles. The [configuration
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
ConfigKit must define supported YAML semantics explicitly rather than inherit ConfigKit must define supported YAML semantics explicitly rather than inherit
parser defaults. Processing order and diagnostics must be deterministic for the parser defaults. Processing order and diagnostics must be deterministic for the
same inputs and options. Invalid input must not produce a successful usable same inputs and options. Invalid input must not produce a successful usable
configuration. Do not run application hooks on invalid typed configuration. configuration. Do not run application hooks on invalid typed configuration.
User-controlled precedence is application defaults, composed base, selected User-controlled precedence is application defaults, ordered underlays, composed
overlay, then explicitly supplied CLI overrides. Applications own flag parsing; main input, selected overlay, then explicitly supplied CLI overrides.
ConfigKit owns overlay semantics and provenance. Domain resolution follows these Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
Domain resolution follows these
inputs and must remain recorded, not become a hidden override channel. inputs and must remain recorded, not become a hidden override channel.
Preserve node-based composition until the effective input is ready for typed Preserve node-based composition until the effective input is ready for typed
@@ -122,7 +220,14 @@ See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
Detailed syntax and operation tables belong to the Detailed syntax and operation tables belong to the
[current configuration contract](../config.md). [current configuration contract](../config.md).
## Ownership and confidentiality The frozen inspection boundary also supports comparing completed same-type
results without reopening sources or rerunning the load lifecycle. The
[configuration contract](../config.md#effective-configuration-diffs) owns
comparison and disclosure behavior, the [component overview](../internal/overview.md)
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
records the accepted decision.
## Ownership And Confidentiality
Returned configuration and metadata must not share mutable state across loads. Returned configuration and metadata must not share mutable state across loads.
ConfigKit must not mutate a result after returning it. Ordinary exported Go ConfigKit must not mutate a result after returning it. Ordinary exported Go
@@ -143,12 +248,12 @@ forwarded blindly when they can include raw input. Do not log configuration
implicitly. Applications remain responsible for classifying their secrets and implicitly. Applications remain responsible for classifying their secrets and
for safe messages supplied by their hooks. for safe messages supplied by their hooks.
## Scope boundaries ## Scope Boundaries
Do not introduce automatic directory discovery, network providers, arbitrary Do not introduce automatic directory discovery, network providers, arbitrary
merge strategies, additional configuration languages, or application runtime merge strategies, additional configuration languages, or application runtime
management without concrete consumer evidence and an explicit design decision. management without concrete consumer evidence and an explicit design decision.
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope) Record significant decisions to exclude capabilities, and their rationale, in
owns the deferred-feature inventory. Significant boundary ADRs. Significant boundary changes require an ADR and corresponding
changes require an ADR and corresponding policy/contract updates; implementation policy/contract updates; implementation convenience alone is not a reason to
convenience alone is not a reason to broaden the public API. broaden the public API.

View File

@@ -1,140 +1,179 @@
# Documentation Policy # Documentation Policy
**Base Policy Version:** 1.1 **Base Policy Version:** 1.11
**Last Updated:** 2026-09-26 **Last Updated**: 2026-10-16
Licensing ownership incorporates the upstream documentation policy v1.5; **Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
unrelated policy revisions remain outside this adoption.
## Purpose ## Purpose
Assign each documentation topic one canonical owner so ConfigKit's documentation This policy assigns each documentation topic to one canonical owner. Its goal is
remains accurate and useful to consumers, maintainers, and implementation agents. to keep ConfigKit's documentation accurate, concise, discoverable, and resistant
to drift for consumers, maintainers, developers, and coding agents.
## Core rules ## Core Rules
### One canonical owner ### One Canonical Owner
Each authoritative fact belongs in one owning document or Go declaration's Each authoritative fact belongs in one document. A non-owning document may give
reference documentation. Other documents may summarize and link to that owner; a short, stable summary for orientation, but it must link to the canonical owner
they must not maintain competing definitions of commands, API contracts, YAML for volatile details.
semantics, defaults, paths, diagnostics, or compatibility guarantees.
### Policies, current behavior, and future work Volatile details include commands, flags, configuration fields and defaults,
module keys, schemas, file names, paths, status codes, retry behavior, and
runtime guarantees. If readers could reasonably treat a statement as a
contract, maintain it only in the owning document.
- Policies state currently binding contributor rules, including constraints on ### Current And Future Behavior
future implementation. They do not imply that features already exist.
- Current API/configuration contracts, consumer guidance, and component
documentation describe implemented behavior only, including partial boundaries.
- ADRs record accepted decisions and rationale. Acceptance is not evidence of
implementation; execution status belongs in roadmaps.
- Roadmaps own unimplemented targets, deferred work, and execution status.
Future implementation plans own their task order, acceptance gates, and progress; Outside `docs/roadmap/`, documentation describes implemented behavior only.
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions. Partial features may be described only to their implemented boundary.
As behavior lands, move its normative definition into the current contract and
replace the roadmap definition with a link. Retire completed roadmap material
as part of release preparation; ADRs preserve significant decision history.
### Audience and examples Architectural constraints may govern future changes. Describe current structure
from implemented behavior.
Consumer documentation explains how to use the library and what consumers own. ADRs may record an accepted architectural decision before implementation. The
Internal documents explain mechanisms and dependencies. Keep implementation roadmap owns implementation status and sequencing until the decision is
mechanics out of consumer guidance unless they affect a meaningful choice. implemented. Current architecture, user, operator, integration, and internal
documentation are updated when the behavior lands.
Complete copyable artifacts belong in `examples/`; Go API examples may live in `docs/roadmap/future.md` owns potential enhancements and their open questions;
executable Go example tests beside the relevant package. Prose should use the task-specific plans own execution order and progress. Record significant
smallest useful excerpt and link to the maintained artifact. Examples must be architectural exclusions and their rationale in ADRs. Do not maintain a roadmap
secret-free and checked against implemented behavior. Clearly label illustrative inventory of rejected proposals. Completed plans retire during release preparation.
future syntax in roadmaps; do not present it as a runnable current example.
### Security and privacy ### Audience And Detail
Do not include real credentials, private keys, environment dumps, sensitive input, Write for the document's stated audience and include only the detail needed for
or private infrastructure details unless intentionally public. Document secret its owned topic. Keep user and operator docs focused on using and operating the
handling, not secret values. application. Developer docs should link to user-facing and external contracts
for their definitions.
## Canonical ownership ### Examples
| Topic | Owner | Boundary | Complete copyable files belong in `examples/`. Documentation may use the
| --- | --- | --- | smallest illustrative snippet needed to explain its owned topic, but should link
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example | to maintained examples for complete copyable files.
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
| Agent rules | `docs/policy/agents.md` | Implementation discipline, proportionality, rules against inventing contracts, and local commit authorization; not application contracts or architecture |
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
| Licensing and attribution rules | `docs/policy/oss.md` | Project licensing conventions, attribution format, notice links, discoverability, and maintenance |
| Project grant | `COPYRIGHT` | Project identity, holders and years, SPDX expression, grant, disclaimer, and third-party statement |
| Project license texts | `LICENSE` | Complete selected license and incorporated companion text |
| Upstream attribution | `docs/oss/attribution.md` | Component names, copyright statements, license identification, and source/license/notice links |
| Standard dependency license texts | `docs/oss/licenses/` | Complete verbatim texts, not project-specific upstream notices |
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
A listed owner is not a requirement to create an empty file before it has useful Executable Go API examples may live beside the relevant package.
content. Create API, consumer, example, and component documents when the corresponding behavior
exists and there is useful content to own. Link only to existing files; use plain
code paths for planned files. Unvalidated candidate and publication work has the
explicit temporary exception recorded in the release policy.
ConfigKit has no owned HTTP service, application CLI, or runtime operations Examples must be valid, secret-free, and tested where practical. Commands and
interface. Do not create those reference documents for consuming applications. configuration used in documentation should match the application.
## Boundary rules ### Security And Privacy
The [OSS policy](oss.md) owns licensing and attribution conventions. COPYRIGHT Documentation and examples must not contain real credentials, private keys,
owns the project grant, LICENSE owns its complete texts, and attribution entries private environment dumps, sensitive source material, or private infrastructure
own component-specific information. README and new release documents provide details unless intentionally public. Document secret-handling mechanisms, not
navigation; the release procedure owns concrete release URLs and publication secret values.
mechanics. Do not duplicate notice lists in those documents.
Architecture owns governing constraints; ADRs explain why; contracts describe ## Canonical Ownership
implemented semantics; internal documentation explains how. A roadmap may link
to all of them but must not become a second current reference.
Release policy owns guarantees; the release procedure owns commands. A release | Topic | Canonical owner | Owned content | Content owned elsewhere |
note cannot redefine a compatibility contract or replace a current-documentation | --- | --- | --- | --- |
update. The note at an immutable tag is that release's historical record. | Product orientation and minimal example | `README.md` | Purpose, current status, shortest useful Go example, and navigation, including licensing and attribution. | Full Go API and configuration contracts, release procedure, and implementation detail. |
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, contributor orientation, baseline validation commands, and canonical links. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
| Application architecture policy | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Policy-set provenance and upstream navigation | `docs/policy/meta.md` | Policy-set origin, local authority, adoption baselines, and upstream adoption and update links. | Task-specific reading triggers, detailed adoption procedures, and formal attribution notices. |
| Agent policy | `docs/policy/agents.md` | Proportionate, idiomatic implementation; rules against inventing requirements, restrictions, guarantees, or policy. | Application contracts, architecture, testing policy, and contributor procedures. |
| Documentation policy | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk sufficiency, boundaries, doubles, coverage, regressions, and test maintenance. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| Release policy | `docs/policy/release.md` | Versioning, compatibility, release integrity and immutability, release documentation, proportionate gates, publication controls, completion, and required versus optional checks. | Repository-specific commands, product installation guidance, CLI version semantics, historical release summaries, and CI implementation detail. |
| Open source licensing and attribution policy | `docs/policy/oss.md` | License defaults and author choices, COPYRIGHT and attribution formats, license/notice links, discoverability, maintenance, and concrete exceptions. | Component entries, license texts, release commands, and dependency security review. |
| Project copyright and license grant | `COPYRIGHT` | Project identity, copyright holders and years, SPDX expression, code grant, separately licensed document scope, warranty disclaimer, and third-party statement. | Complete license texts and upstream component attribution. |
| Project license texts | `LICENSE` | Complete project and incorporated companion license texts, with required project-specific fields. | General licensing policy and upstream component attribution. |
| Upstream component attribution | `docs/oss/attribution.md` | House Format entries for dependencies and adapted documents: identity, material scope, copyright, license, notices, and source links. | General policy, exception reasoning, and complete standard license texts. |
| Standard license texts | `docs/oss/licenses/` | Complete, verbatim standard license texts shared by applicable entries, including copied documentation. | Project-specific upstream notices, attribution prose, and policy interpretation. |
| Repository-specific release procedure | `docs/release.md` | Maintainer prerequisites, candidate preparation, validation and publication commands and automated guards, asynchronous deliverables, delivery status, optional consumer verification, and recovery. | General release requirements, product installation guidance, historical release summaries, and current compatibility contracts. |
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle behavior. | Shared YAML semantics and complete examples. |
| Configuration contract | `docs/config.md` | YAML syntax, composition, precedence, schema, defaults, resolution, validation, secrets, and explanation semantics. | Go symbol contracts, complete examples, and implementation detail. |
| Consumer guidance for the public Go API | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | Go symbol contracts, configuration semantics, and internal implementation detail. |
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
This policy owns what documentation may disclose. Architecture owns security Create supporting documents for existing interfaces and responsibilities when
invariants. Current configuration and Go API contracts own secret-classification substantive content is available.
and rendering behavior when implemented; internal docs own their mechanisms.
## Architectural decision records Do not create empty owners.
Use sequential filenames, such as `0001-separate-composition-and-overlay.md`, ## Boundary Rules
and the lightweight Nygard structure: title, status, date, context, decision,
alternatives considered, and consequences.
Accepted decision content is immutable. A changed decision requires a new ADR ### Orientation
and a superseded status/link in the old one. Navigation links may be repaired
when specification ownership moves without rewriting historical rationale.
Rejected architectural alternatives belong in ADRs; deferred or rejected product
features belong in roadmaps. Do not create an ADR for every routine choice.
## Maintenance and verification Link among orientation, architecture, and internal documents; maintain each
package or behavior description in its canonical owner.
Update the owning current documentation in the same change that implements or ### Go API And Configuration
changes behavior. When ownership moves, remove the old definition and preserve
useful navigation links. Go declaration documentation owns exported symbol behavior. The configuration
contract owns shared YAML and resolution semantics. Consumer guidance explains
tasks and links to those contracts.
### Releases
The release policy defines requirements and invariants that every release must
satisfy. The repository-specific release procedure defines the commands and
workflow maintainers use to satisfy that policy. Release notes record history
at immutable tags. Maintain current behavior in its canonical documentation,
update it with the corresponding behavior changes, and keep release procedures
consistent with adopted policy.
### Project Licensing And Upstream Attribution
README and release documents link to the canonical licensing and attribution
materials under the [OSS policy](oss.md). The release procedure owns URL
construction and publication mechanics.
### Contracts And Implementation
Go API and configuration documents define externally observable behavior.
Internal documents explain implementation and link to the owning contract.
### Security Topics
This policy owns what documentation and examples may contain. Architecture owns
application security invariants. The configuration and public API contracts own
secret-classification, bootstrap, and rendering behavior. Internal docs own
implementation mechanisms only.
## Architecture Decision Records
Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
1. title;
2. status;
3. date;
4. context;
5. decision;
6. alternatives considered;
7. consequences.
Treat the decision content of an accepted ADR as immutable. When a decision
changes, create a new ADR and update the earlier ADR's status to superseded.
Rejected architectural alternatives and significant architectural exclusions
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
ideas. Routine choices and rejected suggestions need no ADR unless they
represent a significant architectural decision.
## Maintenance
When behavior changes, update its canonical owner in the same change. If
ownership moves, remove the old definition and replace it with a link where
navigation remains useful.
Before completing documentation work: Before completing documentation work:
- verify claims against the implementation or clearly identify future design; - verify affected behavior and examples;
- check changed local links, paths, fragments, examples, and commands; - check commands, flags, fields, defaults, schemas, and paths against their
- preserve the distinction between policy, decisions, target behavior, and status; implementation;
- check canonical ownership and remove stale or duplicate definitions; - keep unimplemented behavior in the roadmap, subject to the ADR exception;
- inspect formatting and whitespace; - remove stale references and validate links;
- ensure no sensitive data was introduced. - confirm that non-owning documents summarize and link rather than redefine;
- confirm that no secrets or sensitive private data were added.

27
docs/policy/meta.md Normal file
View File

@@ -0,0 +1,27 @@
# Policy Set Provenance
**Base Policy Version:** 1.0
**Last Updated**: 2026-09-28
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/meta.md)
## Origin And Local Authority
This repository's policy set is adapted from
[Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy).
The adopted local policies govern work in this repository. Upstream revisions
become local policy through deliberate adoption, preserving local decisions.
Each policy's `Upstream Source` identifies its adopted baseline.
## Adoption And Updates
For policy adoption, upstream revision reviews, and updates, consult the
[upstream adoption and update guide](https://gitea.maximumdirect.net/eric/policy/src/branch/main/docs/adoption.md).
That guide and the reference example collection are maintained upstream and
accessed through links. Keep discovery links pointed at the current upstream
project and guide; keep adopted-source and example links pinned to commits.
The local [developer guide](../development.md) routes tasks to applicable
policies. Formal credit and licensing notices for the copied policy material
belong in the local [attribution document](../oss/attribution.md).

View File

@@ -1,26 +1,33 @@
# Open Source Licensing And Attribution Policy # Open Source Licensing And Attribution Policy
**Base Policy Version:** 1.2 **Base Policy Version:** 1.8
**Last Updated**: 2026-09-26 **Last Updated**: 2026-09-28
## ConfigKit applicability **Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
## ConfigKit Applicability
ConfigKit's selected grant is LGPL-3.0-or-later, recorded in ConfigKit's selected grant is LGPL-3.0-or-later, recorded in
[COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3 [COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3
texts in [LICENSE](../../LICENSE). Adoption does not change that grant. texts in [LICENSE](../../LICENSE). The [release procedure](../release.md)
The [release procedure](../release.md) owns the Gitea source host, source-only owns source-only Go module publication through Gitea. No separate hosted release
Go module distribution, and annotated-tag publication mechanism. No separate object or license asset is required for this channel.
hosted download page or license asset is required for this source-only channel.
## Purpose And Scope ## Purpose And Scope
This policy defines project licensing conventions and a repeatable House Format This policy defines project licensing conventions and a repeatable House Format
for upstream attribution and license notices. It covers the project's own code for upstream attribution and license notices. It covers the project's own code
and upstream components incorporated into or distributed with the project, and upstream components incorporated into or distributed with the application
including relevant transitive dependencies and or library, including relevant transitive dependencies and copied code,
copied code or assets. Treatment follows the component's terms and actual use, documentation, or assets.
rather than its classification as a direct dependency. Determine treatment from the component's terms and actual use.
Consulting development guidance or using tooling alone does not require an
attribution entry. Copying or adapting documentation for inclusion in the
repository is different: preserve its applicable attribution and license terms.
A material's role as development guidance does not exempt copied documents.
Apply any express permissions for attribution-free reuse of example formatting.
The baseline is a publicly available source repository with releases published The baseline is a publicly available source repository with releases published
alongside it. The format is independent of language, forge, and build system. alongside it. The format is independent of language, forge, and build system.
@@ -31,18 +38,25 @@ compatibility, or resolve every obligation arising from use or distribution.
The [documentation policy](documentation.md) assigns ownership: The [documentation policy](documentation.md) assigns ownership:
- `COPYRIGHT` contains the project name, copyright statements, SPDX expression, - `COPYRIGHT`: project name, copyright statements, SPDX expression, grant,
license grant, warranty disclaimer, and statement about third-party material. warranty disclaimer, and third-party statement.
- `LICENSE` contains the complete, unmodified project license text, including - `LICENSE` contains the complete, unmodified project license text, including
incorporated companion text. Dependency attribution does not relicense the project. incorporated companion text. Dependency attribution does not relicense the project.
- `docs/oss/attribution.md` contains the canonical component entries. - `docs/oss/attribution.md`: canonical component entries.
- `docs/oss/licenses/` contains complete, verbatim standard license texts, - `docs/oss/licenses/`: complete, verbatim standard and applicable companion texts.
including companion texts where applicable.
Use recognizable license filenames, such as `Apache-2.0.txt` or `LGPL-3.0.txt`. Use recognizable license filenames, such as `Apache-2.0.txt` or `LGPL-3.0.txt`.
A shared standard text may serve multiple entries. Keep explanations outside A shared standard text may serve multiple entries. Keep explanations outside
license texts and preserve distinct versions and exceptions. license texts and preserve distinct versions and exceptions.
## Upstream Reference Examples
Use the commit-pinned upstream examples to create actual project notices.
Retain their links and keep the example collection upstream; keep applicable
license texts locally as specified above. The
[standard license-text collection](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/oss/licenses/README.md)
identifies the available reference texts.
## Project Licensing ## Project Licensing
Unless the author specifies otherwise, use **GPL version 3 or later** Unless the author specifies otherwise, use **GPL version 3 or later**
@@ -57,10 +71,12 @@ license unless the author deliberately changes it. For mixed-purpose projects,
follow the author's selected scope rather than inferring different licenses for follow the author's selected scope rather than inferring different licenses for
individual packages. individual packages.
Keep project name, years, and holders accurate in COPYRIGHT; do not assume the Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/application/COPYRIGHT)
maintainer owns every contribution or advance years automatically. For another or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/library/COPYRIGHT)
selected license, adapt the grant, SPDX expression, disclaimer, and text to create the project’s root `COPYRIGHT` file.
references together. Use verified project names, contribution years, and copyright holders.
For another selected license, adapt the grant, SPDX expression, disclaimer, and
text references together to reflect the author's selected grant.
For GPLv3 projects, copy the complete GPLv3 text into `LICENSE`. For LGPLv3 For GPLv3 projects, copy the complete GPLv3 text into `LICENSE`. For LGPLv3
projects, place the complete LGPLv3 text followed by the complete GPLv3 text in projects, place the complete LGPLv3 text followed by the complete GPLv3 text in
@@ -75,40 +91,70 @@ fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
grant. This convention introduces no blanket requirement for per-file headers; grant. This convention introduces no blanket requirement for per-file headers;
preserve notices or add them where the applicable terms require them. preserve notices or add them where the applicable terms require them.
Identify separately licensed documentation or assets in the project grant, with
links to their attribution and license information. An application's default
code license does not override the terms of incorporated documentation.
## Upstream Attribution House Format ## Upstream Attribution House Format
Each component entry has five fields: Begin `docs/oss/attribution.md` with `# Open Source Attribution Notices`,
followed directly by component entries. These comprise the complete document.
1. **Project name**, normally as the entry heading. Each entry presents these five fields in order, with blank lines between them:
1. **Project name** as a level-two heading. An optional identification paragraph
may follow, such as `Selected module: MODULE at VERSION.` with the module and
version in backticks. For copied documentation, a concise `Material:` sentence
may identify the affected files and whether they were adapted or copied
without modification. A factual qualifier such as `(transitive dependency)`
is permitted. Limit optional identification prose to factual component and
material scope.
2. **Upstream copyright statement or statements**, preserving their wording. 2. **Upstream copyright statement or statements**, preserving their wording.
3. **SPDX license identifier or expression**, when applicable. Put multiple statements in separate paragraphs. Include this field when the
4. **License and notice links**, covering the applicable standard texts and selected upstream source supplies an applicable statement; otherwise, omit it.
upstream project-specific material. 3. **SPDX license identifier or expression**, when applicable, using
5. **Source repository link**, identifying the source corresponding to the `SPDX-License-Identifier: EXPRESSION`.
imported version. 4. **License and notice links**, beginning `License and notices:`, separated
by ` | `, and ending with a period. Label local standard texts by license
name, such as `MIT` or `Apache 2.0`. Label upstream files by their actual
filenames, such as `COPYRIGHT`, `LICENSE`, `NOTICE`, or `PATENTS`.
5. **Source repository link**, rendered exactly as `[Source](COMMIT_URL).`.
The [go-yaml entry](../oss/attribution.md#go-yaml-v4) demonstrates this format Use the upstream [attribution example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/attribution.md)
with the pinned module's actual statements and links. to create the project’s `docs/oss/attribution.md`.
The example illustrates omitted fields and multiple copyrights and licenses.
Replace the uppercase placeholders and illustrative URLs with actual component
information. Local license links are relative to the destination
`docs/oss/attribution.md`; copy the applicable texts into its `licenses/`
directory. Do not copy example entries for components the project does not use.
These are five fields, not a line limit. Include multiple statements and links These are five fields, not a line limit. Omit the SPDX field when no applicable
where needed. Omit the SPDX field when no applicable identifier exists; identify identifier exists; identify nonstandard terms through the linked text. Preserve
nonstandard terms accurately through the linked text. Preserve licensing licensing alternatives and exceptions, and record a selected option when
alternatives and exceptions, and record a selected option when relevant. Do not relevant through the factual license identification. Do not invent an identifier
invent an identifier or silently resolve an unsettled licensing choice. or silently resolve an unsettled licensing choice.
Keep entries factual. Do not add agent-authored explanations of license
expressions, file-level license scope, research performed, retained source
notices, or compliance conclusions. Avoid filler such as "none is invented
here" and embellished link labels such as "upstream LICENSE", "additional
patent grant", or "at the imported commit". Preserve actual required upstream
content; this rule excludes editorial additions, not required notices. Policy
interpretation and exception reasoning belong in the adopted OSS policy or an
appropriate decision record, not the attribution document.
## License Texts And Upstream Links ## License Texts And Upstream Links
Link standard license texts to their local copies. Link project-specific Link standard license texts to their local copies. Link project-specific
notices to the upstream copies instead of storing them locally. Every upstream notices to their upstream copies. Every upstream repository or notice link must
repository or notice link must identify the exact commit corresponding to the identify the exact commit corresponding to the
imported version, including the selected source revision for copied material. imported version, including the selected source revision for copied material.
Do not use moving branches or version tags as substitutes for that commit.
Classify material by its contents, not its filename. An upstream `LICENSE`, Classify material according to its contents. An upstream `LICENSE`,
`COPYING`, or similar file may contain project-specific notices or exceptions `COPYING`, or similar file may contain project-specific notices or exceptions
in addition to standard wording. Link both the local standard text and the in addition to standard wording. Link both the local standard text and the
commit-specific upstream file when needed. Make companion license texts directly commit-specific upstream file when needed. Make companion license texts directly
reachable from the entry rather than leaving readers to infer their location. reachable from the entry.
For project-specific license and attribution material, this policy accepts For project-specific license and attribution material, this policy accepts
direct links to the applicable upstream texts as satisfying reproduction, direct links to the applicable upstream texts as satisfying reproduction,
@@ -124,10 +170,9 @@ source files; the central attribution entry does not authorize stripping them.
## Discoverability ## Discoverability
End the project's README with two friendly statements linking to its actual End the project's README with two friendly statements linking to its actual
grant in `COPYRIGHT` and the attribution document. ConfigKit's [README](../../README.md) grant in `COPYRIGHT` and the attribution document. ConfigKit's
uses "ConfigKit is licensed under the LGPL v3.0 or later" and "Open Source [README](../../README.md) contains the two required statements: one linking to
attribution notices are available", linking the license name to COPYRIGHT and the project grant and one linking to attribution.
the attribution phrase to `docs/oss/attribution.md` relative to the repository root.
Each new checked-in release document and its hosted download page must end Each new checked-in release document and its hosted download page must end
with links whose visible text is `COPYRIGHT | LICENSE | ATTRIBUTION`. Use absolute with links whose visible text is `COPYRIGHT | LICENSE | ATTRIBUTION`. Use absolute
@@ -159,8 +204,10 @@ to implement this policy.
Use the House Format routinely. Raise an exception only for a concrete obligation Use the House Format routinely. Raise an exception only for a concrete obligation
or unresolved rights question it does not address, including any arising from a or unresolved rights question it does not address, including any arising from a
particular distribution channel. Record the issue and selected treatment in the particular distribution channel. Record the issue and selected treatment in the
adopted policy or affected attribution entry as appropriate. Resolve the issue adopted OSS policy or an appropriate decision record. Attribution entries
before the affected use or distribution without reopening settled choices. contain the resulting factual identification and required material, without
repeating that reasoning. Resolve the issue before the affected use or
distribution without reopening settled choices.
Common ownership of projects is neither required nor a blanket exception. Common ownership of projects is neither required nor a blanket exception.
Apply the formatting convention prospectively. Do not rewrite published tags, Apply the formatting convention prospectively. Do not rewrite published tags,

View File

@@ -1,45 +1,39 @@
# Release Policy # Release Policy
**Base Policy Version:** 1.0 **Base Policy Version:** 1.6
**Last Updated:** 2026-09-26 **Last Updated**: 2026-10-09
The licensing-footer requirement incorporates upstream release policy v1.2; **Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
other release-workflow changes from that version are not adopted here.
## Purpose ## Purpose
This policy defines the minimum requirements for selecting, validating, This policy defines a proportionate workflow for validating and publishing an
publishing, verifying, and correcting releases. Its goal is to make every intended source revision under a fresh version. Releases should require little
release intentional, reproducible from an exact source revision, documented routine intervention, avoid repeated work, and preserve published contents.
for consumers, and safe from retrospective replacement.
The repository-specific commands that implement these requirements belong in Adopt stronger verification or reproducibility requirements through an explicit
`docs/release.md`. Historical release summaries belong in `docs/releases/`. project-specific decision justified by the intended audience and support needs.
Neither document may weaken this policy.
## ConfigKit applicability The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted
policy and link to it rather than introduce additional release requirements.
ConfigKit adopts a source-only Go module release model. The source tag and ## ConfigKit Applicability
version-matched release note are the declared deliverables. Standalone binaries,
container images, and separate binary/package-registry publication are not
required. This does not waive source compilation, module hygiene, testing, or
local target-build validation. Post-publication consumer retrieval is optional,
as documented under Policy Exceptions.
The release procedure must define the module path, authoritative remote, ConfigKit publishes a source-only Go module. Its declared deliverables are the
supported Go versions and operating-system/architecture targets, prerequisite immutable annotated source tag and version-matched checked-in release note.
dependency acquisition, and the exact verification commands once established. Standalone binaries, container images, hosted release objects, and separate
The default tests themselves remain deterministic and offline. Additional registry publication are not required. Additional channels require a deliberate
publication channels require documented deliverables and verification before use. update to the policy and procedure.
The Go API, documented YAML/resolution semantics, and structured diagnostic The Go API, documented YAML and resolution semantics, and structured diagnostic
contracts all participate in compatibility review. A signature-preserving change contracts participate in compatibility review. A signature-preserving change
can still break consumers. Human diagnostic wording is stable only where a can still break consumers. Human diagnostic wording is stable only where a
contract expressly promises it. Minimum Go version and platform support changes contract expressly promises it. Minimum Go version and platform support
also require an explicit compatibility assessment. changes also require an explicit compatibility assessment.
### Platform support ### Platform Support
Linux is the release-validated platform. macOS has best-effort support: keeping Linux is the release-validated platform. macOS has best-effort support: keeping
it working remains desirable, but neither native macOS validation nor Darwin it working remains desirable, but neither native macOS validation nor Darwin
@@ -48,53 +42,67 @@ does not by itself block a release. Voluntary checks and fixes are welcome; ther
is no guarantee that each release has been validated on macOS. is no guarantee that each release has been validated on macOS.
The [release procedure](../release.md#release-identity-and-support) owns target The [release procedure](../release.md#release-identity-and-support) owns target
architectures and required Linux checks. This support change applies to upcoming architectures and required Linux checks. Historical release notes and their
releases; historical release notes and their validation records remain unchanged. validation records remain unchanged.
The v0.3.0 release note must explain the change in Compatibility, with version
selection following this policy's existing compatibility rules.
## Release Model ## Release Model And Completion
A release consists of: A source release identifies one exact committed source revision through an
immutable version tag and a version-matched, checked-in release note. The
primary release branch is `main` unless the adopted policy specifies otherwise.
The procedure must name the authoritative publication remote.
- one exact source commit reachable from the repository's primary branch; A successful push of the intended tag, with its source commit available on the
- one immutable version tag identifying that commit; authoritative primary branch, completes the release. The source commit may be
- one version-matched, checked-in release note; and pushed before the tag or published together with it through an atomic push.
- every artifact, image, package, registry entry, or other deliverable the The procedure must choose and document the supported approach.
repository declares for that release.
The repository's primary branch is `main` unless an adopted policy documents ConfigKit uses sequential publication: first push the committed candidate to
an exception. A release candidate must be committed and pushed to that branch authoritative `main`, then invoke ReleaseKit once to validate that candidate and
before its release tag is published. publish its annotated tag. The [release procedure](../release.md) names
`origin`; ReleaseKit checks its exact effective push URL before validation and
tag publication.
The source tag is the authoritative release identity. Published deliverables Release commands must finish at successful publication. They must not wait for
must be traceable to the tagged source and must agree with its version. A or poll CI, require successful CI status, inspect downloadable artifacts, or
repository may publish source only. When it publishes additional deliverables, check consumer installation or Go module availability as completion gates.
its release procedure must enumerate them and define how each is built, A failed or ambiguous push requires resolving the authoritative remote state
identified, published, and verified. before declaring success or retrying; successful pushes do not require a
separate remote read-back gate.
Release automation may validate or publish a release only when its exact Remote tag read-back and fresh consumer retrieval are optional and non-gating
responsibility is documented. Automation must not silently change the source after successful publication. A failed push receives one bounded check of the
candidate, move a published tag, replace a published deliverable, or repair a authoritative ref; do not poll or blindly retry.
release in place.
A successful push of the intended annotated tag completes the source-only release. Repositories may publish source only. Additional binaries, images, packages,
Never wait for CI or use its status as a readiness or completion gate. Optional checksums, or other declared deliverables may be published asynchronously from
post-publication checks must not delay reporting success. 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.
## Versioning And Compatibility ## Versioning And Compatibility
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
no leading zeroes. Each version must be unused locally and in every configured no leading zeroes. Prereleases require an explicit adopted policy defining
remote or publication channel before publication begins. their format and use.
Unless an adopted policy explicitly supports and defines prereleases, ConfigKit does not support prereleases.
prerelease versions are not permitted.
The selected version must be unused locally and on the authoritative remote.
Do not require queries to every configured remote or publication service.
A version known to have already been published must not be reused, even if the
normal guards do not inspect that channel. Adding a distribution channel
requires a deliberate update to the policy and procedure where needed.
For releases at or after `v1.0.0`: For releases at or after `v1.0.0`:
- a major release may make intentional incompatible changes; - a major release may make intentional incompatible changes;
- a minor release may add backward-compatible behavior; and - a minor release may add backward-compatible behavior or raise the Go consumer
minimum as described below; and
- a patch release may make backward-compatible fixes. - a patch release may make backward-compatible fixes.
Before `v1.0.0`, a minor release may change a documented public, operational, Before `v1.0.0`, a minor release may change a documented public, operational,
@@ -102,157 +110,210 @@ configuration, data, integration, or compatibility contract when the release
note explains the impact and required consumer action. A patch release must note explains the impact and required consumer action. A patch release must
not intentionally break those contracts within its minor line. not intentionally break those contracts within its minor line.
A repository with additional compatibility promises must define them in its Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. A release note may explain adopted policy or canonical contract documentation. Release notes explain
a compatibility change, but it must not redefine the current compatibility changes; they do not redefine current compatibility contracts.
contract.
## Release Documentation ## Consumer Minimum And Release Toolchain
Every release made under this policy must include Distinguish the minimum toolchain supported for consumers from the toolchain
`docs/releases/<version>.md` in the tagged source tree. The filename must use maintainers use to build and validate a release. Record the consumer minimum in
the complete version tag, and the document must begin with a title containing the language's standard metadata and explain its support meaning in canonical
the product name and that exact version. documentation. Define the selected release toolchain and its setup commands in
`docs/release.md`. Toolchain selection and upgrade cadence are repository-specific
choices.
Every new release note must contain these sections: For Go repositories, normally declare the initial stable release of the selected
series as the consumer minimum, such as `go 1.26.0`. Use a higher patch minimum
when a dependency or an identified correctness fix requires it, and document
the reason.
```markdown For release-policy purposes, successful build and release validation on the
# <Product> vMAJOR.MINOR.PATCH selected Go patch release are sufficient to presume support across that Go
series, subject to the declared consumer minimum and concrete evidence of
incompatibility. Investigate specific compatibility concerns as they arise.
This release ... Use the selected release toolchain for candidate validation and release builds,
including CI jobs performing those tasks. A newer validation patch version alone
leaves the consumer minimum unchanged.
## Summary A minor release may raise the Go consumer minimum, including from one Go series
to the next, both before and after `v1.0.0`. This is an explicit exception to the
backward-compatibility requirement for minor releases. Patch releases preserve
the consumer minimum. Explain an increase's impact and required consumer action
in the release note. Other incompatible changes remain subject to the versioning
rules above.
## Compatibility ### ConfigKit Toolchain Selection
## Upgrade For minor and major releases, select the newest stable Go series available in
the development environment. For patch releases, retain the series recorded in
`go.mod` and its consumer minimum. Use the latest locally available compatible
patch in the selected series, with `GOTOOLCHAIN=local`, to build the
module-selected ReleaseKit CLI and validate the candidate. The same Go
executable builds the tool and runs its validation commands.
## Changes ## Release Documentation And Preparation
```
Use an explicit statement such as "No action required" when a section has no Every new release must include `docs/releases/<version>.md` in the tagged
substantive content. source tree. Use the complete version tag as the filename and begin with a
title containing the product name and that exact version.
Release notes are immutable historical summaries. They should link to current ConfigKit requires the first line `# ConfigKit <version>`. ReleaseKit's
canonical documentation for exact behavior. Every affected canonical document publication guard checks the release-note H1 for the product and version.
must be updated in the same candidate; a release note must not substitute for
current documentation or defer required documentation changes.
Each new release note must end with the release navigation defined by the Include a short summary and any relevant compatibility impact or upgrade
[OSS policy](oss.md#discoverability). Preserve those links on a hosted download instructions. Choose sections suited to the release and keep the note
page if one is used. The release procedure owns URL construction and channel proportionate to the change.
treatment. This is ordinary preparation, not a new network check or approval
gate; historical release notes are unchanged.
## Candidate Preparation And Validation Keep exact current contracts in their canonical documents and update affected
documentation with the behavior change. Link to those documents from the note
where useful. If annotated tags are used, derive their summary from the
checked-in note.
ConfigKit provides one local release checker. Run it once against the final ConfigKit uses the complete checked-in note as the annotated tag message.
committed candidate using one maintained patch of the minimum supported Go line
on Linux. It checks module hygiene, formatting and whitespace, the full offline
suite with the race detector, static analysis, and CGO-disabled Linux amd64/arm64
builds. Examples, documentation links, consumer fixtures, and package boundaries
are covered by that suite, not separate repeated runs. Native package compilation
is covered by the test run. A second toolchain is optional and non-gating.
Commit the release note and associated changes before release validation. Record Include the release footer defined by the [OSS policy](oss.md#discoverability)
the exact validated commit; revalidate if the candidate changes. Do not require in each new release document. The release procedure defines the concrete URLs
both pre-commit and post-commit full checker runs for unchanged content. and publication mechanics. Prepare these links with the release note; they do
Do not publish uncommitted or unvalidated content. Local check failures still not add a network check or approval gate. If a hosted download page is used in
block publication. Skipped test cases are reported, not separate human sign-off a future channel, preserve the footer there.
requirements.
Compatibility review, release-note accuracy, removal of completed roadmaps, and Handle version selection, release-note content, and retirement of completed
review for unintended files, credentials, private data, and unrelated changes roadmaps during preparation. Preserve planned and deferred work and repair
are ordinary preparation. The executing agent may perform them without routine affected links when retiring roadmaps. Reuse ordinary change review for
human approval or separate manual evidence records. Ask the maintainer only for unintended files, credentials, and private data. Scope release-content review
genuine unresolved decisions or missing authority. A clean existing checkout is to the tagged source.
sufficient; a dedicated clone is optional. Dependency and toolchain update
research belongs to maintenance or relevant changes, not every release. ## Candidate Validation
Provide one publication command that validates the final committed candidate,
checks publication state, and publishes the intended refs. A validation-only
mode or command may be provided for diagnosis, but must not be a required step
before publication repeats the same checks.
ConfigKit uses the module-selected ReleaseKit CLI's `publish
vMAJOR.MINOR.PATCH` command. Its `check` command is optional for diagnosis; it
is not a required preliminary pass to publication.
Run the complete deterministic, offline default test suite once for the final
candidate, following the [testing policy](testing.md). For repositories that
publish builds, verify the required build targets. Select those targets for
the application's actual support commitments; native testing on each platform
and access to particular hardware are not default release prerequisites.
Prepare dependencies and build the tool before offline validation; the
publication command is the required validation run, with no separate successful
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.
CGO-disabled Linux amd64 and arm64 package compilation covers supported
configurations distinct from native race compilation. macOS and newer-toolchain
checks are optional.
Additional gates must address a concrete risk that the existing checks do not
adequately cover. Do not automatically require linting, race testing, module
hygiene audits, documentation audits, credential scans, example validation, or
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.
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
`scripts/check.sh`.
Review module tidiness when dependencies change, not as a release gate.
Release tests must not require real credentials, mutable application services,
paid APIs, provider probes, or inference calls. The procedure may describe dependency
preparation separately from offline validation.
Avoid duplicate validation across release phases. Successful checks for the
same final candidate need not be repeated solely to satisfy another phase;
any reuse must clearly apply to that exact candidate. Persistent validation
caches are not required. If the candidate changes, validate the new candidate.
Cheap publication-state checks may be repeated immediately before pushing
because local or remote state may have changed during validation.
Automate mechanical guards. Human review gates are strongly disfavored: add one
only through an explicit project-specific policy decision explaining the
concrete risk and why ordinary change review or automation is insufficient.
Do not add routine confirmations for checks the command can perform itself.
Compatibility review, release-note accuracy, completed-roadmap retirement, and
review for unintended files or credentials remain ordinary preparation. The
executing agent may perform them without routine human approval or separate
manual evidence records. Publication itself requires authorization.
## Publication Guards ## Publication Guards
Immediately before publication, the maintainer or release automation must Automate the checks needed to publish the intended source under a fresh version:
fetch the current primary branch and tags from the authoritative remote and
verify all of the following:
- the version has the required format and is not already published; - the version has the adopted format and is unused locally and on the
- the current branch is the primary release branch; authoritative remote;
- the worktree and index are clean; - the worktree and index are clean on the primary release branch;
- the current commit is the recorded, validated candidate; - the source being published is the exact candidate that passed validation,
- the authoritative remote branch points to that same commit; including its matching release note H1;
- the matching release note exists in that commit and has the required title - the remote primary branch can be advanced to that candidate without
and sections; discarding remote history, and is published before the tag; and
- release publication is authorized and the local automated gates succeeded for - the tag identifies the validated candidate and only the intended refs are
that commit; and published.
- no local or remote tag already uses the selected version.
Create the version tag against the guarded commit and verify its target before ReleaseKit checks that `origin`'s effective push URL exactly matches the
publishing it. Publish only the exact intended ref and deliverables. Broad authoritative URL before candidate validation. This does not assert the
operations such as `git push --tags` are discouraged because they can publish configured fetch URL or require the release-note H1 to be the first line; the
unreviewed refs unrelated to the release. required `# ConfigKit <version>` first-line format remains a content rule. The
command checks the candidate and unused version again after validation, and
checks local state before creating the tag. Ignored local files do not block
publication.
The repository-specific procedure must make these guards executable or give Fetch the authoritative branch and tag state as needed for these checks.
copyable commands for performing them. Do not force branch or tag updates to bypass a failed guard. Publish explicit
refs rather than all local tags. Do not require CI success, publication-service
availability checks, or a separate manual confirmation for each guard.
## Immutability And Correction ## Immutability And Correction
Published version tags and published deliverables are immutable. Do not move, Published version tags and published contents are immutable. Do not move,
overwrite, recreate, or delete a published tag. Do not replace an artifact, overwrite, recreate, or delete a published tag, or replace a published artifact,
image, package, checksum, signature, provenance statement, or release note checksum, signature, provenance statement, or release note under that version.
under an already published version. The checked-in note at the immutable tag is that release's historical record.
If validation fails before publication, correct the candidate, rerun the If validation fails before publication, correct and commit the candidate and
complete gates, and repeat the publication guards. An unpublished local tag rerun the publication command. If a push fails or its outcome is ambiguous,
may be removed after confirming that it was never published or used by a inspect the authoritative remote before retrying. Remove an unpublished local
release channel. tag only after confirming it was never published or used by a release channel.
An unavailable remote leaves publication status unknown; do not overwrite a tag
to resolve uncertainty.
If publication or post-publication verification reveals a defect, leave the Transient network, authentication, CI, or consumer-availability failures do not
published release intact. Correct the defect on the primary branch, choose a by themselves require a new version.
new patch version or other semantically appropriate new version, write a new
matching release note, and perform the complete release procedure again.
A distribution channel's supported yank, deprecation, or advisory mechanism A defect requiring changes to published source or contents requires a corrected
may warn consumers away from a defective release, but it must not replace that commit, a new semantically appropriate version, a matching release note, and
release's contents or reuse its version. the release procedure again. Leave the original release intact. A channel's
yank, deprecation, or advisory mechanism may warn consumers away from a defective
release without replacing its contents or reusing its version.
## Repository Release Procedure ## Repository Release Procedure
Every adopting repository must maintain `docs/release.md` as the canonical Maintain `docs/release.md` as a short, executable maintainer runbook. It must
maintainer runbook for implementing this policy. It must define, in execution identify prerequisites, the selected release toolchain, the primary branch and
order: authoritative remote, version and note preparation, one publication command with
local checks and guards, source-only deliverables, the successful-tag-push
completion point, and partial or unknown-state recovery.
1. maintainer prerequisites and required access; Use copyable commands where practical. Keep optional diagnostics and consumer
2. version selection and compatibility review; verification clearly optional. The procedure must not turn preparation work,
3. release-note creation; ordinary change review, or post-publication investigation into additional
4. candidate preparation and commit; release gates.
5. one local validation pass, exact candidate recording, and push;
6. publication guards;
7. exact tag and deliverable publication commands;
8. completion on successful tag push and optional post-publication checks; and
9. failure handling and correction.
The procedure should use copyable, fail-fast commands where practical. It must
name the primary branch, authoritative remote, tag form, published
deliverables, supported build targets, release automation responsibilities,
and optional consumer verification path. It must link to this policy instead of
restating or redefining its requirements.
## Policy Exceptions ## Policy Exceptions
Any exception to this policy must be explicitly documented in this section, The following historical exceptions do not change the current release workflow.
including the repository constraint requiring it, its rationale, scope, and
compensating controls. Customizing release mechanics must not silently weaken
the policy's integrity, validation, documentation, verification, or immutability
requirements.
For ConfigKit's source-only releases, the maintainer accepts successful publication
of the intended tag as sufficient completion evidence. This is an explicit
exception to the shared standard's mandatory post-publication verification:
authoritative tag read-back and fresh consumer retrieval are optional and
non-gating. CI status and downstream retrieval availability must not delay release
completion. Compensating controls are local validation of the exact candidate,
automated pre-publication guards, matching remote main, verification of the local
annotated tag target, explicit ref pushes, and immutable published versions.
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
`configkit` as an error-correcting point release because there are no current `configkit` as an error-correcting point release because there are no current
@@ -266,6 +327,3 @@ For v0.2.0 only, the maintainer accepted the successful native macOS check at
only. The final candidate still requires the complete Linux release checker. only. The final candidate still requires the complete Linux release checker.
This exception waives only repeating the macOS check after those documentation This exception waives only repeating the macOS check after those documentation
changes; it does not waive other publication or verification gates. changes; it does not waive other publication or verification gates.
Candidate selection and guarded publication remain mandatory; post-publication
verification is optional in the [release procedure](../release.md).

View File

@@ -1,14 +1,16 @@
# Testing Policy # Testing Policy
**Base Policy Version:** 1.0 **Base Policy Version:** 1.3
**Last Updated:** 2026-09-06 **Last Updated**: 2026-09-28
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
## Purpose ## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**. Our tests exist to make **incorrect changes expensive and correct changes cheap**.
We do not optimize for test count, line coverage, exhaustive isolation, or the fewest possible tests. We optimize for sufficient confidence in important behavior while imposing as little unnecessary friction as possible on future development. We optimize for sufficient confidence in important behavior while minimizing friction on future development.
## ConfigKit-specific priorities ## ConfigKit-specific priorities
@@ -44,32 +46,19 @@ policy does not imply that those tools or tests already exist.
## Every test has a cost ## Every test has a cost
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost. Every test has an immediate and continuing cost: writing, review, execution,
diagnosis, fixture maintenance, and updates when legitimate behavior changes.
A test must be: Tests can also constrain refactoring, duplicate policy, and add failure noise.
- written and reviewed;
- understood by future maintainers and coding agents;
- executed in local and CI workflows;
- diagnosed when it fails;
- updated when legitimate behavior changes;
- maintained as fixtures, APIs, and dependencies evolve; and
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
A test is warranted only when the confidence it provides justifies these costs. A test is warranted only when the confidence it provides justifies these costs.
Evaluate that tradeoff at two levels:
Apply this cost-benefit analysis at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost? 1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would? 2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests. Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. Keep ordinary internal changes inexpensive.
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive. Evaluate maintenance cost against the specific failure being protected. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
## Default testing style ## Default testing style
@@ -81,6 +70,8 @@ Use a **classical/Detroit-style** approach:
- Prefer package-level behavioral tests over tests coupled to private helpers or internal call sequences. - Prefer package-level behavioral tests over tests coupled to private helpers or internal call sequences.
- Treat exact collaborator interactions as testable behavior only when the interaction itself is a requirement. - Treat exact collaborator interactions as testable behavior only when the interaction itself is a requirement.
Examples of appropriate seams include clocks, randomness, subprocesses, remote APIs, object storage, email, and paid LLM calls.
For ConfigKit, controlled input readers and filesystem failures are useful seams. For ConfigKit, controlled input readers and filesystem failures are useful seams.
Prefer actual parsing, composition, and decoding over mocks of their call order. Prefer actual parsing, composition, and decoding over mocks of their call order.
@@ -92,7 +83,7 @@ Control clocks, randomness, environment variables, and other process-global or m
## What deserves tests ## What deserves tests
Prioritize tests for: To the extent the following features are present in the application, prioritize tests for:
1. Public and package-level contracts. 1. Public and package-level contracts.
2. Domain rules and important invariants. 2. Domain rules and important invariants.
@@ -102,15 +93,27 @@ Prioritize tests for:
6. Previously observed or plausible regressions. 6. Previously observed or plausible regressions.
7. Representative integration and end-to-end workflows. 7. Representative integration and end-to-end workflows.
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation. A package-level contract comprises behavior relied upon by another package or major collaborator.
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer. Choose tests for requested behavior and established contracts. Tests must not
introduce new restrictions, guarantees, or threat scenarios merely because they
could be asserted. Follow the [agent policy](agents.md) when deciding whether a
behavior belongs to the contract; current implementation behavior alone is not
sufficient authority.
For established behavior involving **data integrity, destructive operations,
compatibility, security, concurrency, idempotency, or recovery**, favor durable
tests for realistic failures whose consequences warrant protection. Identify
the failure and the existing contract it would violate. These categories call
for attention, not an automatic test inventory or permission to strengthen the
application's guarantees. Account for protection already provided at other layers.
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere. Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
## Choose the right test boundary ## Choose the right test boundary
Test through the narrowest stable boundary that expresses the behavior clearly. Choose the narrowest stable boundary that expresses the behavior clearly and
provides durable confidence with minimal incidental coupling.
This is often the package API, but it may instead be: This is often the package API, but it may instead be:
@@ -118,28 +121,15 @@ This is often the package API, but it may instead be:
- a package-level operation when several internal collaborators jointly produce the behavior; or - a package-level operation when several internal collaborators jointly produce the behavior; or
- a larger integration boundary when correctness emerges from interaction with a real dependency. - a larger integration boundary when correctness emerges from interaction with a real dependency.
Do not force all behavior through oversized end-to-end tests. Do not test every private helper merely because it exists. Choose the boundary that gives durable confidence with the least incidental coupling.
## Test behavior, not implementation ## Test behavior, not implementation
A test should protect a decision, contract, or invariant—not memorialize the current implementation. A test should protect a selected decision, contract, or invariant.
Before adding or retaining a test, ask: A test is suspect when it primarily detects changes to private constants or
helpers, equivalent operation ordering, incidental formatting, equivalent
algorithms, or internal structure that preserves behavior.
> What realistic defect would this test catch? Behavioral tests should normally remain stable through internal refactoring that preserves the contract.
A test is suspect when its main purpose is to detect that someone:
- changed an internal constant;
- renamed or split a private helper;
- reordered equivalent internal operations;
- changed incidental formatting;
- replaced one correct algorithm with another; or
- refactored internal object structure without changing behavior.
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
## Expected effects of different changes ## Expected effects of different changes
@@ -156,12 +146,13 @@ A test failing is not the same as a test needing to be edited. Many tests may co
## Separate mechanism from policy ## Separate mechanism from policy
Configurable thresholds and defaults must not be duplicated throughout the test suite. Derive expectations from test-controlled configuration and relationships.
For example, do not encode an internal concurrency limit indirectly: For this example, assume the selected contract rejects a request to start more
processes than a configured limit. Do not encode its production default indirectly:
```go ```go
// Production policy: // Production default:
const maxConcurrency = 4 const maxConcurrency = 4
// Brittle test: // Brittle test:
@@ -179,25 +170,30 @@ require.NoError(t, runner.Start(limit))
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency) require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
``` ```
The test should prove: For this selected contract, the test should prove:
- the configured limit is accepted; and - the configured limit is accepted; and
- one beyond the configured limit is rejected. - one beyond the configured limit is rejected.
Rejection is not a general requirement for concurrency control. If the selected
contract queues excess work or limits active workers while accepting larger
batches, test that behavior instead. Do not add rejection or a configurable
limit solely to reproduce this example.
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement. The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals. Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior.
For concurrency limits, test both kinds of behavior when relevant: For established concurrency limits, distinguish these behaviors where the contract requires them:
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly. 1. **Configuration handling:** values are accepted or rejected according to the selected contract.
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit. 2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value. Use a test-controlled limit and measure the behavior relative to that limit.
## Avoid semantic duplication across layers ## Assign each behavior a test owner
Each behavior should have a clear test owner. For ConfigKit, assign ownership as follows:
- YAML boundary tests own syntax and policy enforcement. - YAML boundary tests own syntax and policy enforcement.
- Composition and overlay tests own their operation semantics. - Composition and overlay tests own their operation semantics.
@@ -209,9 +205,7 @@ Each behavior should have a clear test owner.
These are responsibility boundaries, not a required package layout. These are responsibility boundaries, not a required package layout.
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files. Give each layer its distinct responsibility and keep expectation updates localized to the contract being changed.
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
## Use test doubles deliberately ## Use test doubles deliberately
@@ -232,104 +226,77 @@ Mocks are appropriate when the contract includes facts such as:
- an expensive API is called no more than once; or - an expensive API is called no more than once; or
- a security audit event is emitted. - a security audit event is emitted.
Do not use mocks merely to isolate every object or reproduce the implementation's call graph.
## Go-specific guidance ## Go-specific guidance
Use: Select techniques by behavior and risk:
- table-driven tests for meaningful behavioral categories and boundaries; - table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior; - `t.TempDir()` for real filesystem behavior;
- fuzz tests for parsers, normalization, path handling, and broad input spaces; - `httptest.Server` for realistic HTTP interactions;
- fuzz tests when exploring broad input spaces, such as parsers or path handling, adds useful protection beyond representative cases;
- golden files only when the complete output is intentionally stable; - golden files only when the complete output is intentionally stable;
- integration tests where correctness depends on component interaction; and - integration tests where correctness depends on component interaction; and
- a small number of representative end-to-end tests. - a small number of representative end-to-end tests when assembled workflows expose meaningful risks not adequately covered at narrower boundaries.
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields. Use exact error-wording assertions only when the wording is contractual.
At the public library boundary, prefer diagnostic codes, paths, and source At the public library boundary, prefer diagnostic codes, paths, and source
locations over complete message snapshots. Assert wording only when contractual. locations over complete message snapshots. Assert wording only when contractual.
Golden-file updates must require an explicit local flag. CI must not update golden files automatically, and reviewers must inspect the semantic diff before accepting an update. Golden-file updates must require an explicit local flag. CI must not update golden files automatically, and reviewers must inspect the semantic diff before accepting an update.
Keep tests readable and direct. Test helpers and fixture frameworks must earn their own maintenance cost; do not build elaborate test infrastructure for small or isolated needs. Keep tests readable and direct. Size test helpers and fixture frameworks to the maintenance benefit they provide.
## Coverage ## Coverage
Coverage is a diagnostic, not a target. Use coverage to locate untested critical branches and unexpectedly weak packages. Evaluate test quality through meaningful behavioral protection and choose coverage according to risk.
Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone. Pure domain logic will often warrant higher coverage than CLI wiring or external adapters. Uneven coverage is acceptable when it reflects risk.
Dense configuration semantics will often warrant higher coverage than simple input wiring. Uneven coverage is acceptable when it reflects risk.
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
## Regression tests ## Regression tests
A bug fix should normally include a regression test that fails before the fix and passes afterward. A bug fix should normally include a regression test that fails before the fix and passes afterward.
Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant; do not preserve accidental implementation details from the original bug. Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant.
Not every historical bug requires a permanent test. If the underlying design has made recurrence impossible, the test has become redundant, or a stronger invariant test now subsumes it, remove or consolidate it. Remove or consolidate a test when the underlying design has made recurrence impossible, its protection is redundant, or a stronger invariant test subsumes it.
## Deleting or rewriting tests ## Deleting or rewriting tests
Tests are maintained code, not permanent historical artifacts. Delete or rewrite tests when maintenance cost exceeds their continuing
protective value.
Delete or rewrite a test when its maintenance cost exceeds the confidence it provides.
Strong candidates include tests that: Strong candidates include tests that:
- require updates after harmless internal changes; - require updates after harmless internal changes;
- directly assert private constants without protecting a real contract; - assert incidental private constants or snapshot large amounts of incidental output;
- duplicate the same policy across several layers;
- verify mock choreography rather than outcomes; - verify mock choreography rather than outcomes;
- snapshot large amounts of incidental output; - duplicate policy across layers, retest trivial helpers covered by package
- test trivial private helpers already exercised through stable package behavior; behavior, or protect risks covered more effectively elsewhere;
- protect risks already covered more effectively elsewhere;
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or - are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
- no longer correspond to a plausible failure mode. - no longer correspond to a plausible failure mode.
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them. Several brittle tests may encode one genuine requirement. Consolidate them into one durable behavior-level or invariant test.
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
## Reviewing a proposed test ## Reviewing a proposed test
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test. Use the following questions when a proposed test needs closer evaluation. Significant test additions should be reviewable against them; written answers are optional for routine tests.
1. What realistic defect would it catch? 1. Which requested behavior or established contract does it protect, and what realistic defect would violate it?
2. How likely is that defect? 2. How likely and consequential is that defect, and is it already protected elsewhere?
3. How consequential would it be? 3. What is the narrowest stable boundary that can test it clearly?
4. Is the behavior already protected elsewhere? 4. Would a correct internal refactor or a change to an incidental default require test edits?
5. At which layer should this behavior be owned? 5. Does its confidence justify its maintenance, execution, and diagnostic cost, or would a smaller test suffice?
6. Does the test assert a durable contract or an incidental implementation detail?
7. Could the implementation be refactored without changing the behavior and without editing this test?
8. What should cause this test to fail?
9. What legitimate changes should not cause this test to fail?
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
11. Is there a smaller or more direct test that protects the same risk?
Do not add the test when its expected lifetime cost exceeds its expected protective value.
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
## Definition of sufficient ## Definition of sufficient
A test suite is sufficient when: A test suite is sufficient when:
- important contracts and invariants are protected; - important requested behavior and established contracts are protected against realistic, consequential failures;
- meaningful boundaries and failure modes are exercised; - boundaries and component interactions have coverage where they introduce meaningful risks not protected elsewhere;
- realistic and consequential regressions are credibly protected against silent recurrence; - failures provide useful, distinct signals;
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected; - tests usually remain stable through legitimate internal changes; and
- important external boundaries have realistic integration coverage;
- representative complete workflows are tested;
- failures provide useful signal rather than redundant noise;
- legitimate internal changes usually do not require test edits; and
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details. - additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
Sufficiency is a risk judgment, not a coverage percentage or test count. Reassess it as the library, its consumers, and the consequences of failure evolve. Reassess sufficiency as the application, its users, and the consequences of
failure evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.

View File

@@ -1,252 +1,237 @@
# ConfigKit Release Procedure # ConfigKit Release Procedure
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md). This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
ConfigKit publishes one source-only Go module: the immutable source tag and the ConfigKit publishes a source-only Go module. The immutable annotated tag and
matching checked-in release note are the deliverables. There is no application matching checked-in release note are the deliverables; there is no binary,
binary, container image, generated package, or separate registry publication. container image, generated package, or separate registry publication.
## Release identity and support ## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit` - Module: `gitea.maximumdirect.net/eric/ConfigKit`
- Primary branch: `main` - Primary branch: `main`
- Authoritative remote: `origin`, expected to fetch from - Authoritative remote: `origin`
`https://gitea.maximumdirect.net/eric/ConfigKit.git` - Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported - Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
- Minimum Go language/toolchain line: Go 1.25, release-validated with one maintained - Minimum Go version: the first stable patch of the selected Go series, or a
patch of that line; newer-toolchain testing is optional and non-gating higher patch when required by a dependency or specific correctness fix, as
- Release-gated build targets: Linux on amd64 and arm64 recorded in `go.mod`; see the
- Best-effort targets: macOS on amd64 and arm64, under the [consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain)
- Release-gated targets: Linux amd64 and arm64
- Best-effort targets: macOS amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support) [platform support policy](policy/release.md#platform-support)
The local Linux checker includes native race tests and CGO-disabled cross-builds. Release validation runs one offline race-enabled Go suite, one full vet pass,
Its output reports skipped filesystem cases; no separate evidence record or and CGO-disabled package compilation for both Linux targets. macOS and CI are
human sign-off is required. Native macOS checks and Darwin cross-builds are not release gates; do not wait for or poll CI.
optional and do not gate publication.
CI is never a release gate. Do not wait for it, poll its status, or delay reporting ## 1. Select Go and prepare the tool
completion because it is pending, unavailable, or failed.
## 1. Prepare access and tools Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
tests. Choose the release type before selecting Go. For a minor or major release,
select the newest stable Go series available in the development environment.
For a patch release, retain the series recorded in `go.mod` and leave its consumer
minimum unchanged. Follow the
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
The release maintainer owns this procedure. Use a clean checkout (a dedicated clone is optional) with Git, Bash 3.2 or newer, Put the selected series' latest locally available compatible patch first on
a maintained Go 1.25 patch, and the compiler needed for native race tests. The checker supports the stock macOS `PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module ReleaseKit and run its child validation commands. Set local toolchain selection
access through the normal credential manager before starting. Keep tokens, passwords, and verify it:
SSH material, and environment dumps outside the repository and command transcript.
Verify access without displaying credentials:
```sh ```sh
git remote get-url origin export GOTOOLCHAIN=local
git ls-remote origin HEAD go version
go env GOVERSION
command -v go
``` ```
The remote URL must equal the authoritative URL above. Acquire the selected Only when a minor or major release advances the consumer minimum to a newer Go
toolchain and dependencies explicitly. For example, with the maintained patch series, derive that series' initial stable release from the selected executable:
used when this procedure was simplified:
```sh ```sh
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14 selected_go=$(GOTOOLCHAIN=local go env GOVERSION)
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version selected_series=${selected_go#go}
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download selected_series=${selected_series%.*}
release_minimum="$selected_series.0"
go mod edit -go="$release_minimum"
``` ```
Keep toolchains maintained, but do not require latest-version discovery for each If a dependency or identified correctness fix requires a higher patch minimum,
release. The checker sets `GOPROXY=off` and fails if the cache is incomplete. use that required version instead and document the reason. When the series is
Toolchain and YAML-dependency update research belongs to periodic maintenance or unchanged, leave the minimum unchanged unless a minor or major release
relevant dependency changes. No renewed approval of an unchanged pin is required. 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.
Use Bash with `set -euo pipefail` for the command blocks below so any failed gate Prepare dependencies whether or not the minimum changes:
stops the sequence. Publication requires user authorization; the agent policy's
local-commit authorization is not permission to push or release.
## 2. Select the version and review compatibility
Choose an unused stable version and compare the candidate with the previous tag.
For the first release, review the whole public surface and current contracts.
```sh ```sh
release_version=v0.1.0 go mod download
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
printf 'remote tag already exists: %s\n' "$release_version" >&2
exit 1
else
test $? -eq 2
fi
git log --oneline --decorate --no-merges
``` ```
Review exported Go signatures and ownership, YAML and scalar behavior, precedence, Build the tool selected by `go.mod` into temporary storage outside the checkout.
diagnostic codes, minimum Go version, and platform support. Before `v1.0.0`, any Ensure `$TMPDIR` names a system temporary location, not the repository. These
intentional incompatible minor-release change must be explained with consumer action; commands never install another ReleaseKit version:
patch releases must remain compatible within their minor line. Review the maintained
consumer examples and the migration guide even when signatures did not change.
When dependencies change, review parser behavior and migration impact as part of ```sh
that change. Keep Compatibility accurate about the dependency pin, but do not releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
perform an external version survey for every patch release. 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
```
## 3. Write the release note 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
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
executable first on `PATH` through the publication command.
Create `docs/releases/$release_version.md` before selecting the candidate. It must Verify access to the authoritative remote without displaying credentials:
begin with `# ConfigKit $release_version` and contain `Summary`, `Compatibility`,
`Upgrade`, and `Changes` sections. Use “No action required” where appropriate. Link
to current canonical contracts instead of copying their definitions. Do not create a
release note until there is a real candidate and reviewed content.
End each new release note with the footer required by the ```sh
[OSS policy](policy/oss.md#discoverability). For the selected version, use these git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main
absolute Gitea URLs (replace every `VERSION` with the complete intended tag): ```
Publication requires explicit authorization. Local implementation commits do
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
migration guidance. Explain any consumer-minimum increase and required consumer
action under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
Create `docs/releases/<version>.md`. Its first line must be exactly
`# ConfigKit <version>`, and it must end with the license and attribution footer:
```markdown ```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md) [COPYRIGHT](https://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)
``` ```
Confirm the three target files exist in the candidate and the version is correct Replace `VERSION` with the full tag and confirm the three target files exist in
during ordinary note review. The future tag need not resolve remotely yet; do the candidate. Retire completed roadmaps, preserve unfinished and deferred
not add publication-time network checks. ConfigKit publishes source tags with work, and repair affected links. Review the complete candidate for unintended
checked-in notes, not separate hosted download pages. If a hosted release page files and credentials, then commit it on `main`. These are ordinary preparation
is used later, reuse the checked-in note as its body and retain this footer. tasks, not separate approval gates. Any `go.mod` change belongs in this
Do not retrofit published notes or add separate downloadable license assets. candidate.
## 4. Validate the source candidate ## 3. Push the candidate to `main`
Before release validation, finish compatibility review, inspect the diff and file Push only that committed candidate to the literal authoritative URL. This does
list for unintended content or credentials, retire completed roadmaps, repair not follow local tags:
links, and review the release note. These are ordinary preparation tasks the
executing agent may perform, not separate human approvals or evidence records.
Ask the maintainer only for an unresolved product decision or missing authority.
Commit the candidate, staging only its intended changes, then run the checker
once against that clean commit. Do not run both everyday and release checkers to
repeat the same gates.
```sh ```sh
git status --short git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main
# Stage the specific files belonging to the candidate, then review:
git diff --staged
git commit --message "Prepare ConfigKit $release_version"
test -z "$(git status --porcelain=v1 --untracked-files=all)"
candidate_commit=$(git rev-parse HEAD)
./scripts/release-check.sh
test "$(git rev-parse HEAD)" = "$candidate_commit"
test -z "$(git status --porcelain=v1 --untracked-files=all)"
printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-release-candidate)"
``` ```
If the final candidate is already committed, omit the staging and commit commands; ReleaseKit requires authoritative `main` to equal this candidate before it
do not create an empty commit. The checker validates module manifests in a validates. A failure before this push means ReleaseKit publication has not
disposable copy, formatting, whitespace, the full offline suite once with the race started. If the push result is uncertain, inspect authoritative `main` and
detector and verbose skip reporting, vet once, and CGO-disabled Linux amd64/arm64 continue only when it is confirmed at the candidate. If validation later fails,
builds. The suite includes examples, consumer fixtures, documentation links, and no release tag is published; correct the issue in a new commit, push the new
package boundaries; no separate reruns are required. No additional linter is candidate to `main`, and invoke validation again.
claimed. Sibling-consumer links are checked when those checkouts exist and are
otherwise inapplicable in an isolated checkout.
A changed candidate requires validation again. Do not reuse a recorded candidate ## 4. Publish the committed candidate
from an older revision. The record is written only after successful local checks.
## 5. Prepare publication With the authorized candidate already on authoritative `main`, invoke the
module-selected native executable exactly once:
The following guarded sequence pushes only `main` and the selected tag. Local
validation failures block it; CI does not. Keep the selected `release_version`
from step 2 in the same shell. If starting a new shell, set it explicitly again.
## 6. Run publication guards
Immediately before tagging, fetch the authoritative branch and tags, restore the
recorded variables if this is a new shell, and execute every guard:
```sh ```sh
candidate_commit=$(cat "$(git rev-parse --git-path configkit-release-candidate)") "$releasekit_bin" publish vMAJOR.MINOR.PATCH
git fetch --prune origin main
git fetch --tags origin
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain=v1 --untracked-files=all)"
test "$(git rev-parse HEAD)" = "$candidate_commit"
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
test -f "docs/releases/$release_version.md"
test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $release_version"
for heading in Summary Compatibility Upgrade Changes; do
grep -Fxq "## $heading" "docs/releases/$release_version.md"
done
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
printf 'remote tag already exists: %s\n' "$release_version" >&2
exit 1
else
test $? -eq 2
fi
git push origin HEAD:refs/heads/main
git fetch origin main
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
test "$(git rev-parse HEAD)" = "$candidate_commit"
test -z "$(git status --porcelain=v1 --untracked-files=all)"
``` ```
The recorded local validation must apply to exactly `$candidate_commit`. No Replace the placeholder with the selected version. ReleaseKit checks the
separate human sign-off, CI status, or manual evidence document is required. effective push URL against `expected_push_url`, verifies the candidate on
`main`, and checks the release-note H1 for the product and version. It then
## 7. Tag and publish the exact ref 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
Create one annotated tag at the guarded commit, verify its peeled target, and push arm64. It publishes only the annotated tag for that candidate. Confirmed
only that ref. Do not use `git push --tags`. 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:
```sh ```sh
git tag --annotate "$release_version" --message "ConfigKit $release_version" "$candidate_commit" "$releasekit_bin" check vMAJOR.MINOR.PATCH
test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
git push origin "refs/tags/$release_version:refs/tags/$release_version"
``` ```
A successful push of the intended tag completes the release. Report success now; Status 0 confirms publication, status 1 reports a known publication failure or
do not wait for CI, module propagation, or any post-publication check. No Gitea conflict, status 2 reports usage, profile, or version errors, and status 3 means
release object or binary upload is required. the push outcome remains unknown. Preserve both the native exit status and its
diagnostics.
## 8. Optional post-publication checks 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.
Only when useful or requested, read back the remote tag or retrieve the exact ## 5. Recover from a failed or uncertain tag push
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:
```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"
```
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.
## 6. 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 version into a new module without a local `replace` directive. These checks are
optional and non-gating; their failure or unavailability does not delay release optional and non-gating. Do not poll CI or wait for module propagation. For
completion. Do not poll CI or wait for module propagation. Private-host credentials direct-origin verification, use the exact published version in a temporary
must already be configured. Example direct-origin verification: consumer module:
```sh ```sh
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}') release_version=vMAJOR.MINOR.PATCH
test "$remote_tag_commit" = "$candidate_commit"
consumer_dir=$(mktemp -d) consumer_dir=$(mktemp -d)
( (
cd "$consumer_dir" cd "$consumer_dir"
go mod init example.invalid/configkit-release-check go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version" GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version"
cat > main.go <<'EOF'
package main
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
func main() {
_ = configkit.NoSource()
}
EOF
gofmt -w main.go
go test ./...
go build ./...
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit
) )
rm -rf -- "$consumer_dir"
``` ```
If performed, the final `go list` output should show the exact module path and The output should show the exact module path and selected version. Report this
`$release_version`. Report optional verification separately from publication success. optional check separately from publication success.
## 9. Handle failures without replacement
Before publication, fix and commit the candidate, validate it, record the new
exact candidate, and repeat the guarded publication sequence. An unpublished local tag may be deleted only after verifying it
was never pushed or used by another release channel.
After publication, never move, delete, or recreate the tag and never replace its
release note. Correct the defect on `main`, choose a new semantically appropriate
version, and repeat this procedure. A supported advisory or deprecation mechanism
may warn consumers about the old version but cannot change its contents.

45
docs/releases/v0.4.2.md Normal file
View File

@@ -0,0 +1,45 @@
# ConfigKit v0.4.2
This patch release simplifies and safeguards source publication and aligns
maintainer policy and licensing guidance. Library behavior is unchanged.
## Summary
Maintainers can now publish a prepared release with one guarded command. It
validates the exact committed candidate, checks the authoritative destination,
and pushes only the candidate on `main` and its annotated version tag. The
[release procedure](../release.md) explains preparation and partial-publication
recovery; successful tag publication completes the source-only release without
waiting for CI or consumer retrieval.
## Compatibility
No Go code, public API, configuration semantics, diagnostic behavior, or
dependencies changed from v0.4.1. The minimum Go version remains 1.25. Linux
amd64 and arm64 remain release-validated; macOS remains best-effort. The YAML
dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
## Upgrade
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.2
```
No application-code or configuration migration is required from v0.4.1.
## Changes
- Add guarded single-command publication with exact candidate, version, and
remote checks; note-derived annotated tags; and bounded failed-push recovery.
- Keep one offline race suite, one full vet pass, and Linux amd64/arm64 builds
as release gates. Formatting and whitespace remain development checks;
dependency tidiness is reviewed when dependencies change.
- Align adopted policies and contributor guidance with their pinned upstream
revision, and include the CC BY-SA 4.0 license text and attribution for
adapted policy material. The project grant remains LGPL-3.0-or-later.
- Retire completed release-workflow and policy-alignment planning records while
preserving their history in Git. Deferred scope remains documented.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.2/docs/oss/attribution.md)

53
docs/releases/v0.5.0.md Normal file
View File

@@ -0,0 +1,53 @@
# ConfigKit v0.5.0
This minor release adds source-backed layered configuration and built-in sibling
inheritance, so applications can load one complete configuration while retaining
presence, source context, and explainable derivation.
## Summary
- `LoadLayered` combines ordered underlay groups beneath the main input, selected
overlay, and explicit CLI overrides. Inputs within a group still compose
strictly; precedence tiers combine mappings recursively. The main input
remains the owner of the optional schema-version check.
- `NewFilesystem(...).File(...)` accepts caller-owned `fs.FS` sources, and
`Mapping(...)` snapshots supported built-in values as a labeled source.
`Result.SourceContext` and `Resolution.SourceContext` expose passive resource
contexts for values from file-backed sources.
- `extends` now resolves a mapping against one named sibling after source
precedence and before typed decoding. Inherited values count as explicit
input. Origins, history, explanations, and redacted rendering retain the
source and the structural path by which a value reached its destination.
Review corrections preserve immutable nested-history evidence and bounded
expansion work.
See the [configuration contract](../config.md#layered-loading),
[inheritance rules](../config.md#sibling-inheritance), and
[consumer examples](../consumers/README.md) for current behavior.
## Compatibility and upgrade
This is an intentional pre-v1 incompatible minor release for configurations
that use `extends` as ordinary data. That key is now reserved wherever an input
mapping occurs; it is consumed before decoding. Rename any such YAML key and
corresponding Go field, then rebuild. No collection registration or opt-in flag
is available. Configurations without `extends` retain their existing meaning.
Applications adopting the new features may move source-backed defaults into
explicit `Underlay` groups, use `SourceContext` for relative resources, or
replace application-owned sibling merging with one `extends` relationship per
mapping. Adoption is optional. The maintained
[named-preset example](../consumers/README.md#named-sibling-inheritance)
shows inherited `false` and destination-local nested resolution.
Select the new module version:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.5.0
```
The minimum Go line remains 1.25. Linux amd64 and arm64 remain the
release-validated targets; macOS remains best-effort. The YAML dependency is
unchanged at `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/docs/oss/attribution.md)

63
docs/releases/v0.6.0.md Normal file
View File

@@ -0,0 +1,63 @@
# ConfigKit v0.6.0
This minor release reduces avoidable configuration-loading and inspection work,
adopts ReleaseKit for source publication, and raises the consumer Go minimum to
1.26.0. The public configuration model remains unchanged from v0.5.0.
## Changes
- Reduce temporary path allocations by sizing pointer-formatting buffers,
combining final metadata traversal, reusing parsed sensitivity paths, and
avoiding path construction when detecting inheritance. Effective and
historical metadata indexes retain independently owned paths.
- Replace repeated resolution-coverage, metadata-update, CLI-overlap, and
sensitivity-propagation scans with private indexes and incremental processing.
Preserve diagnostic ordering, history, contextual path shapes, and redaction.
- Make presence-only metadata queries avoid materializing provenance. Prepare
resolution replay descriptors and immutable mapping and inheritance-history
lookups for inspection, and use ordered membership indexes for provenance
collections. Returned records remain detached; explanations still replay
complete trees and render on demand.
- Add a maintained public-API startup construction benchmark. The initial path
formatting and metadata traversal experiments reproduced allocation reductions
across local and downstream workloads. Later algorithmic improvements were
validated behaviorally without a new benchmark campaign. Busy-host timings
were inconclusive; no fixed speedup or allocation threshold is promised.
- Replace ConfigKit-owned release scripts with a small profile and the
module-selected ReleaseKit v0.3.0 tool. The
[release procedure](../release.md) documents candidate preparation, tag-only
publication, and failed-push recovery. Existing release checks and the
successful-tag-push completion point remain unchanged; CI is not a gate.
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
validation toolchain. Retire completed planning and measurement records into
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
## Compatibility and upgrade
The consumer minimum increases from Go 1.25.0 to **Go 1.26.0**. Install Go 1.26.0
or newer before upgrading, and update application build and CI environments that
still use Go 1.25. The `go.mod` directive records the consumer minimum; using a
newer validation patch does not itself raise that minimum. See the
[consumer minimum policy](../policy/release.md#consumer-minimum-and-release-toolchain).
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.6.0
```
No application-code or configuration migration is required from v0.5.0. Public
Go signatures, strict within-tier composition, recursive tier precedence,
sibling inheritance, presence/null distinctions, provenance, secret handling,
and synchronous completion retain their existing contracts. See the
[configuration contract](../config.md) and
[consumer guide](../consumers/README.md).
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`. ReleaseKit is a
declared Go tool dependency for maintainer commands, not a library runtime
import; its requirements participate in ConfigKit's module graph. Linux amd64
and arm64 remain release-validated targets, and macOS remains best-effort.
Maintainers should use the current release procedure instead of the retired
`scripts/release.sh` and `scripts/release-check.sh` commands.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/docs/oss/attribution.md)

58
docs/releases/v0.7.0.md Normal file
View File

@@ -0,0 +1,58 @@
# ConfigKit v0.7.0
This minor release adds structured comparison of completed configurations.
Applications can inspect changes between two configuration files or the
effective impact of a selected overlay, with ConfigKit handling comparison and
safe value disclosure while applications own presentation.
## Changes
- Add `Diff[T](left, right *Result[T]) ([]Difference, error)` through the public
facade. It compares frozen effective inspection values from successful loads,
including defaults and recorded application resolution, rather than source
text or later mutations of `Result.Config`.
- Return detached changed-region records containing a semantic path, safe left
and right `ValueSnapshot` values, and `HiddenChanged`. Mappings compare
recursively, sequences report atomic changes, and records follow canonical
pointer order. Presence distinguishes additions and removals from explicit
null; equal effective values produce no records regardless of provenance or
loading history.
- Protect both snapshots with sensitivity from both loads, including historical
and dependency-derived classification. Compatible regions retain public
siblings; incompatible structural replacements use complete-region masking
where required. `HiddenChanged` identifies actual changes withheld by masking,
rather than merely indicating that a snapshot contains redacted content.
- Preserve visible public scalar changes when only historical sensitive
descendants remain, including additions and removals. Historical explanation
redaction and masking of incompatible present shapes remain intact.
- Add maintained file-comparison and base-versus-overlay examples with
consumer-owned formatting, plus an executable public API example. See the
[consumer guide](../consumers/README.md#effective-configuration-diffs) and
[configuration contract](../config.md#effective-configuration-diffs).
## Compatibility and upgrade
No application-code or configuration migration is required from v0.6.0.
Adopting the new API is optional. Loading, composition, inheritance, provenance,
existing inspection, and secret-handling contracts remain unchanged.
Load both configurations successfully before calling `Diff`; nil or
uninitialized results return an error with no records. Comparison does not
reopen sources, rerun callbacks, or reread environment variables. Typed secret
references compare by configured reference name, not resolved credential bytes,
so credential rotation behind an unchanged name is not reported. Returned
snapshots describe changed regions, not cross-protected full-document context.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.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.3.0. Linux amd64 and arm64 remain release-validated targets;
macOS remains best-effort. Publication follows the existing
[release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/docs/oss/attribution.md)

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

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

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

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

View File

@@ -1,28 +0,0 @@
# Deferred Scope
This document owns intentionally excluded features, not a committed delivery plan.
The [architecture policy](../policy/architecture.md) governs changes to these
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
and consumer adoption in the [consumer guide](../consumers/README.md).
## Explicit non-goals and deferred scope
General environment-variable configuration overlays are not supported. Typed secret
references and explicit directory bootstrap are the environment convention. Literal
credential configuration and implicit secret-directory discovery are unsupported.
The initial release deliberately excludes:
- automatic `conf.d` discovery;
- remote imports, arbitrary providers, and additional configuration formats;
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
- parser-specific or custom structural-decoding extension APIs;
- generic dynamic-subtree conversion and module-registry APIs;
- automatic schema migrations;
- hot reload, runtime resource replacement, and remote resource validation;
- a bundled CLI framework or application commands; and
- configuration diffs and fingerprints.
Applications may build their own validate, effective-value, source, bootstrap, and
explanation commands around the library. Broadening these boundaries requires
concrete consumer evidence, compatibility review, and an explicit design change.

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

@@ -0,0 +1,171 @@
# Potential Future Features
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
in ADRs; this document does not maintain a list of rejected proposals.
## 1. Identified record sequences
Status: deferred. PromptKit proposed this capability from its retired proposal 2,
**Namespaced Collections As Identified Records**. The original proposal is in the
PromptKit repository at
`b5866dd:docs/roadmap/configuration-collections-identified-records.md`.
PromptKit selected mounted mappings instead; this feature is not a dependency
of that design or a request to restore multiple installed prompt versions.
A narrower [sequence placement and composition feature](../config.md#strict-document-composition)
is implemented: consumers can place imported mappings as sequence items and
register a single literal string identity field for duplicate-rejecting strict
composition. Precedence still replaces sequences as whole values. This entry
tracks broader identified-record behavior that remains deferred and is not part
of that implemented contract.
### Motivation and proposed behavior
Allow applications to declare that selected sequences contain records identified
by explicit fields, while retaining sequence-shaped output for slice decoding
and effective YAML. This could make composite identities and independently
authored catalogs more convenient than encoding identity in nested mapping keys.
Current [composition](../config.md#strict-document-composition) appends distinct
records only at explicitly registered paths; other sequences remain atomic.
Selected [overlays](../config.md#selected-overlays) always treat sequences
atomically.
The proposed broader opt-in registration would extend the current reusable
application definition from one identity field to a composite identity made of
an ordered, nonempty set of distinct direct string-field names. For example,
records at `profiles` could be identified by `(namespace, id)`. Records
`(provider, brief)`
and `(application, brief)` would coexist, and a later layer could update the
provider record's temperature while retaining its backend and model.
The requested semantics, all still subject to design review, are:
- Match exact string tuples, preserving component boundaries and spelling,
including empty strings. Every contribution supplies its complete identity;
defaults, inheritance, filenames, and normalization do not supply it.
- Retain distinct identities. Matching records from strict peers recursively
compose disjoint body fields and conflict on overlapping non-mapping body
values, even equal values. Equal identity fields act as structural keys rather
than conflicts and retain their contributing source evidence.
- Higher layers recursively overlay matching bodies. A changed identity denotes
a different record, not a rename. Proposed ordering is first contribution in
precedence-assembly order; matching records keep position and new ones append.
- Reject malformed identities, non-mapping records, non-null non-sequence values
at registered paths, and duplicate tuples within one source sequence. Later
layers cannot repair malformed structural identity in an acquired contribution.
- Treat an empty registered sequence as contributing no records; whole-path null
retains ordinary conflict/replacement semantics. Unregistered sequences,
including nested message lists, retain existing atomic behavior.
- Support record inheritance within the same registered sequence using an
`extends` mapping containing the complete parent tuple. Resolve only final
active relationships after precedence, preserve the child's identity, overlay
its body on the parent's body, and consume the directive before decoding.
Preserve cancellation, missing-parent/cycle errors, and declaring resource
contexts. Existing string-based mapping-sibling inheritance stays unchanged.
Integrate ordinary and layered loads, imports, mounted paths, selected overlays,
and whole-sequence CLI overrides. Keep origins, presence, history, sensitivity,
and resource contexts attached to the correct record despite different source
and final indexes. Diagnostics must retain source locations without exposing
sensitive identity values. Final inspection may use snapshot-specific sequence
indexes; existing atomic sequence diffs need not become identity-aware.
Successful results would have valid, unique identities, including after defaults
and application resolution, without a second merge or inheritance pass. Existing
source-version checks remain independent; consumed headers cannot supply missing
record identities. No identity-selector path syntax, arbitrary merge callbacks,
wildcard registration, per-record deletion/rename operator, multiple inheritance,
or cross-path parent references are requested.
### Questions to resolve before implementation
- **Need and delivery scope:** does an active consumer benefit enough to justify
the added semantics over keyed mappings? Are composite identities required?
Staging composition before inheritance is possible but would leave part of
the request incomplete.
- **Defaults and callbacks:** do default records merge by identity or retain
existing sequence-replacement behavior? How are callback-created duplicates,
identity edits, and reordering validated and reflected in provenance?
- **Null, empty, and order:** what happens when null clears a collection and a
later layer reintroduces records? How do absent and explicitly empty sequences
interact with defaults and first-contribution order?
- **Inheritance references:** how do peers compose relationship declarations,
and how are complete references enforced without assembling partial tuples
accidentally? What happens if identity fields overlap structural controls?
- **Registration paths:** how do overlapping registrations and paths beneath
sequence indexes behave when assembly changes those indexes?
- **Evidence and lifecycle:** how are record identities mapped between source
trees, history, inheritance, callback snapshots, and final indexes without
mixing records' provenance or sensitivity? Where do identity checks run relative
to header consumption and other structural processing?
Keep the feature deferred until its consumer value and full lifecycle design
justify reconsidering the sequence-merging exclusion. A correct final sequence
alone would not establish correct inspection or disclosure behavior.
## 2. Required version declarations for selected sources
Status: deferred. PromptKit requested a way to require version declarations on
a selected subset of source files while permitting other fragments to omit them.
### Motivation and proposed behavior
PromptKit could require each collection's root file (for example,
`collection.yml` or `meta.yml`, with naming still undecided) to declare an
accepted specification version. Other files would use the existing
[check-when-present behavior](../config.md#shared-source-version-checks).
This would enforce a rule that otherwise remains a documentation convention.
All declarations would use the same application-owned `Definition.Version`
path and acceptance policy. The proposal concerns where declarations are
required; it does not introduce independently versioned collections or subtrees.
A root declaration would not prove that headerless descendants declare a
compatible version or replace final configuration validation.
### Questions to resolve before implementation
- **Selection API:** should callers mark explicit inputs, select sources by
path or predicate, or annotate imports? An input-level requirement is simpler,
but cannot by itself select a root reached only through an import.
- **Scope:** does a requirement apply only to the selected document or also to
its imports? Applying it only to that document best fits PromptKit's request;
imported fragments could still declare versions checked by the shared policy.
- **Mode interaction:** how should selection interact with disabled source
checks? Require an enabled mode, or activate checking for selected sources?
Selection would strengthen check-when-present behavior and add no requirement
beyond the load-wide required mode.
- **Source handling and evidence:** how should selection work across file,
reader, byte, and mapping inputs, glob matches, and repeated imports? Define
behavior for absent optional inputs and preserve source-local diagnostics,
inspection evidence, and existing sensitivity handling.
## 3. Performance experiments
Status: deferred pending evidence. The completed performance audit identified
four internal optimization candidates for separate investigation:
- Branch-local explanation replay instead of the current whole-tree replay.
- Redesigning the private metadata-copy handoff while preserving detached public
records.
- Caching schema descriptors across loads.
- Indexing complex contributor-trace membership while preserving its equality
rules and compact linked traces.
These are experiments, not committed implementation work or promised speedups.
Reconsider them with evidence that the benefit justifies the ownership,
retained-memory, or equivalence risks. The completed audit and implementation
evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
### Questions to resolve before implementation
- Which representative workloads show enough remaining cost to justify each
optimization, and what measurements would establish a useful improvement?
- Can narrower replay and membership indexes preserve explanation results,
ordering, provenance, and sensitivity behavior?
- Can metadata handoff changes preserve detached public records and ownership
across loads without introducing aliasing?
- What cache lifetime and concurrency design would make schema reuse worthwhile
without excessive retained memory?

View File

@@ -30,6 +30,32 @@ func ExampleNoSource() {
// Output: 4 0 <nil> // Output: 4 0 <nil>
} }
func ExampleDiff() {
type Config struct {
Workers int `yaml:"workers"`
}
before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2}))
if err != nil {
fmt.Println(err)
return
}
after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4}))
if err != nil {
fmt.Println(err)
return
}
differences, err := configkit.Diff(before, after)
if err != nil {
fmt.Println(err)
return
}
for _, difference := range differences {
fmt.Printf("%s: %s -> %s\n", difference.Path.String(),
strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML))
}
// Output: /workers: 2 -> 4
}
func ExampleValidation_Error() { func ExampleValidation_Error() {
type Config struct { type Config struct {
First string `yaml:"first"` First string `yaml:"first"`

View File

@@ -52,6 +52,22 @@ func Example_seriatim() {
// Output: visitor true the event occurred <nil> <nil> // Output: visitor true the event occurred <nil> <nil>
} }
func Example_inheritedPresets() {
result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml")))
if err != nil {
fmt.Println(err)
return
}
path := configkit.Keys("presets", "custom", "enabled")
info, _ := result.Metadata().Lookup(path)
custom := result.Config.Presets["custom"]
fmt.Println(custom.Enabled, result.Metadata().IsExplicit(path), custom.Settings["secondary"]["width"])
fmt.Println(info.Origin.Locations[0].SourceID, info.Origin.Inheritance[0].From)
// Output:
// false true 20
// source-1 /presets/base/enabled
}
func Example_weatherStartup() { func Example_weatherStartup() {
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED" const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN" const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"

View File

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

View File

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

136
examples/consumers/diff.go Normal file
View File

@@ -0,0 +1,136 @@
package consumers
import (
"fmt"
"strconv"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// FileComparisonConfig is a small application-owned shape for comparing
// caller-selected configuration files.
type FileComparisonConfig struct {
Name string `yaml:"name"`
Entries map[string]any `yaml:"entries"`
}
// CompareFiles loads two caller-selected files with the same application
// definition, then formats only the safe records returned by configkit.Diff.
func CompareFiles(leftPath, rightPath string) (string, error) {
options := configkit.Options[FileComparisonConfig]{
Definition: configkit.Definition[FileComparisonConfig]{
Defaults: func() FileComparisonConfig {
return FileComparisonConfig{Entries: map[string]any{}}
},
},
}
left, err := configkit.Load(configkit.File(leftPath), options)
if err != nil {
return "", fmt.Errorf("load left configuration: %w", err)
}
right, err := configkit.Load(configkit.File(rightPath), options)
if err != nil {
return "", fmt.Errorf("load right configuration: %w", err)
}
differences, err := configkit.Diff(left, right)
if err != nil {
return "", err
}
return FormatDifferences(differences), nil
}
// RolloutConfig models a service endpoint whose URL is derived by the
// application resolver and whose credential reference is classified.
type RolloutConfig struct {
Service RolloutService `yaml:"service"`
}
// RolloutService contains endpoint settings and a classified credential.
type RolloutService struct {
Endpoint RolloutEndpoint `yaml:"endpoint"`
Credential RolloutCredential `yaml:"credential"`
}
// RolloutEndpoint is the effective destination used by the application.
type RolloutEndpoint struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
URL string `yaml:"url"`
}
// RolloutCredential holds only a typed secret reference in configuration.
type RolloutCredential struct {
Token configkit.Secret `yaml:"token" configkit:"secret"`
}
// LoadRollout loads a base file, optionally with one selected configuration
// overlay. Resolution derives the effective URL after the overlay is applied.
func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) {
return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{
Overlay: overlayName,
Definition: configkit.Definition[RolloutConfig]{
Resolve: resolveRollout,
},
})
}
// CompareRolloutOverlay compares the effective base configuration with that
// same file plus one selected overlay. It presents only safe diff records.
func CompareRolloutOverlay(basePath, overlayName string) (string, error) {
base, err := LoadRollout(basePath, "")
if err != nil {
return "", fmt.Errorf("load base configuration: %w", err)
}
withOverlay, err := LoadRollout(basePath, overlayName)
if err != nil {
return "", fmt.Errorf("load overlay configuration: %w", err)
}
differences, err := configkit.Diff(base, withOverlay)
if err != nil {
return "", err
}
return FormatDifferences(differences), nil
}
func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error {
endpoint := &configuration.Service.Endpoint
if endpoint.Host == "" || endpoint.Port <= 0 {
return nil
}
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",
configkit.Keys("service", "endpoint", "host"),
configkit.Keys("service", "endpoint", "port"))
}
return nil
}
// FormatDifferences is consumer-owned presentation. It distinguishes absence
// through Present and reports withheld changes through HiddenChanged without
// interpreting redaction marker text.
func FormatDifferences(differences []configkit.Difference) string {
if len(differences) == 0 {
return "No effective changes.\n"
}
var output strings.Builder
for _, difference := range differences {
fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(),
formatSnapshot(difference.Left), formatSnapshot(difference.Right))
if difference.HiddenChanged {
output.WriteString(" hidden-change")
}
output.WriteByte('\n')
}
return output.String()
}
func formatSnapshot(snapshot configkit.ValueSnapshot) string {
if !snapshot.Present {
return "<absent>"
}
return strconv.Quote(snapshot.YAML)
}

View File

@@ -0,0 +1,187 @@
package consumers_test
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func ExampleCompareFiles() {
directory, err := os.MkdirTemp("", "configkit-diff-files-")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(directory)
leftPath := filepath.Join(directory, "before.yml")
rightPath := filepath.Join(directory, "after.yml")
if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil {
fmt.Println(err)
return
}
if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil {
fmt.Println(err)
return
}
output, err := consumers.CompareFiles(leftPath, rightPath)
if err != nil {
fmt.Println(err)
return
}
fmt.Print(output)
// Output:
// /entries/added: left=<absent> right="null\n"
// /name: left="before\n" right="after\n"
}
func ExampleCompareRolloutOverlay() {
const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN"
const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN"
defer preserveEnvironment(baseToken)()
defer preserveEnvironment(overlayToken)()
_ = os.Setenv(baseToken, "disposable-example-base-credential")
_ = os.Setenv(overlayToken, "disposable-example-overlay-credential")
directory, err := os.MkdirTemp("", "configkit-diff-overlay-")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(directory)
basePath := filepath.Join(directory, "config.yml")
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
fmt.Println(err)
return
}
base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n"
overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n"
if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil {
fmt.Println(err)
return
}
if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil {
fmt.Println(err)
return
}
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
if err != nil {
fmt.Println(err)
return
}
fmt.Print(output)
// Output:
// /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change
// /service/endpoint/host: left="base.example\n" right="canary.example\n"
// /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n"
}
func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) {
directory := t.TempDir()
leftPath := filepath.Join(directory, "before.yml")
rightPath := filepath.Join(directory, "after.yml")
writeConfiguration(t, leftPath, "name: before\nentries: {}\n")
writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n")
output, err := consumers.CompareFiles(leftPath, rightPath)
if err != nil {
t.Fatal(err)
}
want := "/entries/added: left=<absent> right=\"null\\n\"\n" +
"/name: left=\"before\\n\" right=\"after\\n\"\n"
if output != want {
t.Fatalf("comparison output = %q, want %q", output, want)
}
if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output {
t.Fatalf("repeat output = %q, %v", again, err)
}
}
func TestCompareFilesReturnsEachLoadError(t *testing.T) {
directory := t.TempDir()
leftPath := filepath.Join(directory, "before.yml")
rightPath := filepath.Join(directory, "after.yml")
writeConfiguration(t, leftPath, "name: before\n")
writeConfiguration(t, rightPath, "unknown: invalid\n")
output, err := consumers.CompareFiles(leftPath, rightPath)
if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") {
t.Fatalf("right load failure = %q, %v", output, err)
}
if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") {
t.Fatalf("left load failure = %v", err)
}
}
func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) {
t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential")
t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential")
directory := t.TempDir()
basePath := filepath.Join(directory, "config.yml")
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
t.Fatal(err)
}
writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n")
writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n")
base, err := consumers.LoadRollout(basePath, "")
if err != nil {
t.Fatal(err)
}
withOverlay, err := consumers.LoadRollout(basePath, "canary")
if err != nil {
t.Fatal(err)
}
if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 {
t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint)
}
urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url"))
if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" {
t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found)
}
differences, err := configkit.Diff(base, withOverlay)
if err != nil {
t.Fatal(err)
}
if len(differences) != 3 {
t.Fatalf("differences = %#v", differences)
}
wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"}
for index, difference := range differences {
if got := difference.Path.String(); got != wantPaths[index] {
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
}
}
secret := differences[0]
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("classified credential difference = %#v", secret)
}
if strings.Contains(consumers.FormatDifferences(differences), "disposable-") {
t.Fatal("formatted differences exposed credential contents")
}
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
if err != nil || output != consumers.FormatDifferences(differences) {
t.Fatalf("formatted base/overlay comparison = %q, %v", output, err)
}
}
func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) {
if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" {
t.Fatalf("no-change output = %q", got)
}
}
func writeConfiguration(t *testing.T, filename, contents string) {
t.Helper()
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,20 @@
package consumers
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
// PresetCatalog is ordinary application data; no inheritance fields or
// registration hooks are required in the typed configuration.
type PresetCatalog struct {
Presets map[string]Preset `yaml:"presets"`
}
type Preset struct {
Enabled bool `yaml:"enabled"`
Settings map[string]map[string]int `yaml:"settings"`
}
// LoadPresets performs one load; callers may inspect inherited presence and
// source origins on the returned result before choosing a preset to execute.
func LoadPresets(source configkit.Source) (*configkit.Result[PresetCatalog], error) {
return configkit.Load[PresetCatalog](source)
}

View File

@@ -0,0 +1,35 @@
package consumers_test
import (
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {
result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml")))
if err != nil {
t.Fatal(err)
}
if result.Config.Presets["base"].Settings["secondary"]["width"] != 10 ||
result.Config.Presets["custom"].Settings["secondary"]["width"] != 20 ||
result.Config.Presets["custom"].Enabled {
t.Fatalf("expanded presets = %#v", result.Config.Presets)
}
enabled := configkit.Keys("presets", "custom", "enabled")
if !result.Metadata().IsExplicit(enabled) {
t.Fatal("inherited false lost explicit presence")
}
info, found := result.Metadata().Lookup(enabled)
if !found || info.Origin.Locations[0].SourceID != "source-1" ||
len(info.Origin.Inheritance) != 1 || info.Origin.Inheritance[0].From.String() != "/presets/base/enabled" {
t.Fatalf("origin and derivation = %#v, %v", info, found)
}
nested := configkit.Keys("presets", "custom", "settings", "secondary", "width")
info, found = result.Metadata().Lookup(nested)
if !found || len(info.Origin.Inheritance) != 1 ||
info.Origin.Inheritance[0].From.String() != "/presets/custom/settings/primary/width" {
t.Fatalf("destination-local derivation = %#v, %v", info, found)
}
}

View File

@@ -0,0 +1,119 @@
package consumers
import (
"embed"
"errors"
"io/fs"
"path"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
var bundledFiles embed.FS
// BundledConfig is a small configuration loaded directly from an embedded file.
type BundledConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
}
type PromptBundleConfig struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Prompts map[string]BundlePrompt `yaml:"prompts"`
}
type BundlePrompt struct {
Summary string `yaml:"summary"`
}
// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose
// destinations are declared relative to the entry document.
func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports")
if err != nil {
return nil, err
}
return configkit.Load[PromptBundleConfig](
configkit.NewFilesystem("prompt bundle", files).File("config.yml"),
configkit.Options[PromptBundleConfig]{Imports: true},
)
}
// LoadBundled demonstrates a native filesystem source without layered options.
func LoadBundled() (*configkit.Result[BundledConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/native-source")
if err != nil {
return nil, err
}
return configkit.Load[BundledConfig](configkit.NewFilesystem("bundled", files).File("config.yml"))
}
// CatalogConfig keeps resource names and domain rules in the application.
type CatalogConfig struct {
Mode string `yaml:"mode"`
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
Templates map[string]string `yaml:"templates"`
Labels map[string]string `yaml:"labels"`
}
// LoadCatalog combines two underlay tiers beneath an independently located
// host main file. ConfigKit assembles and validates the complete result once.
func LoadCatalog(mainPath string) (*configkit.Result[CatalogConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/layered-bundle")
if err != nil {
return nil, err
}
bundle := configkit.NewFilesystem("catalog bundle", files)
return configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)},
configkit.LayeredOptions[CatalogConfig]{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: bundle.File("defaults/config.yml"), Imports: true}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("local policy", map[string]any{
"workers": 2,
"labels": map[string]any{"tenant": "example"},
})}}},
},
Definition: configkit.Definition[CatalogConfig]{
Defaults: func() CatalogConfig { return CatalogConfig{Mode: "demo"} },
Validate: validateCatalog,
},
})
}
func validateCatalog(configuration *CatalogConfig, validation *configkit.Validation) {
if configuration.Mode != "demo" && configuration.Mode != "production" {
validation.Error(configkit.Keys("mode"), "must be demo or production")
}
if configuration.Workers < 0 {
validation.Error(configkit.Keys("workers"), "must be nonnegative")
}
if name := configuration.Templates["welcome"]; name == "." || !fs.ValidPath(name) {
validation.Error(configkit.Keys("templates", "welcome"), "must be a relative resource name")
}
}
// ReadWelcome follows the effective template field's one declaring source to
// its retained resource context. Opening and interpreting the resource remain
// application work; this does not reopen configuration or change metadata.
func ReadWelcome(result *configkit.Result[CatalogConfig]) (string, error) {
if result == nil {
return "", errors.New("catalog result is nil")
}
info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome"))
if !found || len(info.Origin.Locations) != 1 {
return "", errors.New("welcome template has no single declaring source")
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found {
return "", errors.New("welcome template has no resource context")
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Templates["welcome"]))
if err != nil {
return "", err
}
return string(data), nil
}

View File

@@ -0,0 +1,151 @@
package consumers_test
import (
"errors"
"fmt"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_nativeFilesystem() {
result, err := consumers.LoadBundled()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result.Config.Name, result.Config.Enabled)
// Output: embedded false
}
func Example_rootRelativeImports() {
result, err := consumers.LoadPromptBundle()
if err != nil {
fmt.Println(err)
return
}
source := result.Sources()[2]
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
}
func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) {
result, err := consumers.LoadPromptBundle()
if err != nil {
t.Fatal(err)
}
if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." {
t.Fatalf("root import result = %#v", result.Config)
}
sources := result.Sources()
if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() ||
sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() ||
sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" ||
sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID {
t.Fatalf("root import sources = %#v", sources)
}
}
func Example_layeredCatalog() {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil {
fmt.Println(err)
return
}
welcome, err := consumers.ReadWelcome(result)
fmt.Println(result.Config.Mode, result.Config.Enabled, result.Config.Workers,
result.Config.Labels["origin"], result.Config.Labels["tenant"], strings.TrimSpace(welcome), err)
// Output: production false 0 bundle example Welcome from the bundle. <nil>
}
func Example_sourceVersionAdoption() {
result, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent)
if err != nil {
fmt.Println(err)
return
}
checks, redacted := 0, true
for _, source := range result.Sources() {
if check := source.VersionCheck; check != nil {
checks++
redacted = redacted && check.Redacted && check.Version.Text == ""
}
}
required, requiredErr := consumers.LoadVersionedService(configkit.SourceVersionModeRequired)
fmt.Println(result.Config.Service.Version, result.Config.Service.Metadata.Release,
checks, redacted, required != nil && requiredErr == nil)
// Output: service-v2 application-2026.10 2 true true
}
func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
for _, mode := range []configkit.SourceVersionMode{
configkit.SourceVersionModeIfPresent,
configkit.SourceVersionModeRequired,
} {
result, err := consumers.LoadVersionedService(mode)
if err != nil || result == nil || result.Config.Service.Version != "service-v2" ||
result.Config.Service.Name != "example service" || result.Config.Service.Endpoint != "https://service.example.invalid" ||
result.Config.Service.Metadata.Release != "application-2026.10" {
t.Fatalf("versioned service mode %d = %#v, %v", mode, result, err)
}
sources := result.Sources()
if len(sources) != 2 {
t.Fatalf("versioned service sources = %#v", sources)
}
for _, source := range sources {
check := source.VersionCheck
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
!check.Redacted || check.Version.Text != "" {
t.Fatalf("safe source version evidence = %#v", sources)
}
}
}
_, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent,
configkit.Override{Path: configkit.Keys("service", "version"), Value: "cli-v3", Flag: "--service-version"})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
loadError.Diagnostics()[0].Code != configkit.CodeOverrideVersion ||
loadError.Diagnostics()[0].Path.String() != "/service/version" {
t.Fatalf("mounted main version override error = %v", err)
}
}
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"enabled", "workers"} {
info, found := result.Metadata().Lookup(configkit.Keys(key))
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase {
t.Fatalf("%s presence = %#v, %v", key, info, found)
}
}
info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome"))
if !found || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 {
t.Fatalf("welcome origin = %#v, %v", info, found)
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found || context.BaseDir != "defaults" {
t.Fatalf("welcome context = %#v, %v", context, found)
}
welcome, err := consumers.ReadWelcome(result)
if err != nil || strings.TrimSpace(welcome) != "Welcome from the bundle." {
t.Fatalf("welcome = %q, %v", welcome, err)
}
if result.Config.Labels["tenant"] != "example" || result.Config.Labels["origin"] != "bundle" {
t.Fatalf("recursive underlay labels = %#v", result.Config.Labels)
}
}
func TestCatalogValidationRejectsInvalidMode(t *testing.T) {
_, err := consumers.LoadCatalog(fixture("layered-main", "invalid.yml"))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 ||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
loadError.Diagnostics()[0].Path.String() != "/mode" {
t.Fatalf("invalid mode error = %v", err)
}
}

View File

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

View File

@@ -0,0 +1,82 @@
package consumers_test
import (
"errors"
"fmt"
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_identifiedPromptCatalog() {
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
if err != nil {
fmt.Println(err)
return
}
ids := make([]string, 0, len(result.Config.Prompts))
for _, prompt := range result.Config.Prompts {
ids = append(ids, prompt.ID)
}
fmt.Println(ids, err)
// Output: [main curated weather welcome] <nil>
}
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("load prompt catalog: %v (%#v)", err, loadError.Diagnostics())
}
t.Fatal(err)
}
if len(result.Config.Prompts) != 4 {
t.Fatalf("prompt records = %#v", result.Config.Prompts)
}
ids := []string{"main", "curated", "weather", "welcome"}
for index, id := range ids {
if result.Config.Prompts[index].ID != id {
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
}
}
for _, prompt := range result.Config.Prompts {
if prompt.ID == "default" {
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
}
}
var itemImports, helperImports int
for _, source := range result.Sources() {
switch filepath.Base(source.Path) {
case "curated.yml", "generated-weather.yml", "generated-welcome.yml":
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
t.Fatalf("item source placement = %#v", source)
}
itemImports++
case "curated-id.yml":
if source.PlacementMode != configkit.PlacementModeMapping || source.ImportedFrom == nil {
t.Fatalf("helper source = %#v", source)
}
helperImports++
}
}
if itemImports != 3 || helperImports != 1 {
t.Fatalf("item imports = %d, helper imports = %d, sources = %#v", itemImports, helperImports, result.Sources())
}
if result.Config.Prompts[1].ID != "curated" {
t.Fatalf("helper did not provide prompt ID: %#v", result.Config.Prompts[1])
}
}
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
loadError.Diagnostics()[0].Path.String() != "/prompts/0/messages" {
t.Fatalf("consumer domain validation error = %v", err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
presets:
base:
enabled: false
settings:
primary: {width: 10}
secondary: {extends: primary}
custom:
extends: base
settings:
primary: {width: 20}

View File

@@ -0,0 +1,7 @@
configkit:
imports:
- path: ../shared.yml
enabled: true
workers: 4
templates:
welcome: messages/welcome.txt

View File

@@ -0,0 +1 @@
Welcome from the bundle.

View File

@@ -0,0 +1,2 @@
labels:
origin: bundle

View File

@@ -0,0 +1,3 @@
mode: production
enabled: false
workers: 0

View File

@@ -0,0 +1 @@
mode: unsupported

View File

@@ -0,0 +1,2 @@
name: embedded
enabled: false

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
configkit:
imports:
- path: prompts/curated.yml
destination: [prompts]
placement: sequence-item
- glob: prompts/generated-*.yml
destination: [prompts]
placement: sequence-item
prompts:
- id: main
name: Main authored prompt
messages: [Use the main prompt.]

View File

@@ -0,0 +1,4 @@
prompts:
- id: invalid
name: Invalid prompt
messages: []

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
id: welcome
name: Welcome
messages:
- Welcome the user.

View File

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

View File

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

View File

@@ -31,6 +31,9 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) {
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](), reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](), reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](), reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
reflect.TypeFor[configkit.Filesystem](), reflect.TypeFor[configkit.ResourceContext](),
reflect.TypeFor[configkit.Input](), reflect.TypeFor[configkit.Underlay](),
reflect.TypeFor[configkit.LayeredOptions[struct{}]](),
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](), reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](), reflect.TypeFor[configkit.Result[struct{}]](),
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](), reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),

View File

@@ -26,6 +26,7 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
{"IsExplicit", func() { state.resolution.IsExplicit(path) }}, {"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }}, {"InputInfo", func() { state.resolution.InputInfo(path) }},
{"Source", func() { state.resolution.Source("source") }}, {"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }}, {"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }}, {"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},

705
facade_destination_test.go Normal file
View File

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

327
facade_diff_test.go Normal file
View File

@@ -0,0 +1,327 @@
package configkit_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type facadeDiffConfig struct {
Value string `yaml:"value"`
Count int `yaml:"count"`
Items []string `yaml:"items"`
Dynamic map[string]any `yaml:"dynamic"`
}
type facadeDiffSecretConfig struct {
Secret configkit.Secret `yaml:"secret"`
}
func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testing.T) {
type configuration struct {
Items []any `yaml:"items"`
}
load := func(public string) *configkit.Result[configuration] {
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" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Items[0] = "public scalar"
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
return result
}
left, right := load("1"), load("2")
history, ok := left.Explain(configkit.Keys("items").Index(0).Key("secret"))
if !ok || history.Effective.Present || len(history.History) == 0 {
t.Fatalf("removed historical secret = %#v, known=%v", history, ok)
}
maskedHistory := false
for _, change := range history.History {
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
}
if !maskedHistory {
t.Fatal("historical sensitivity was not retained")
}
differences, err := configkit.Diff(left, right)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/items" {
t.Fatalf("sequence difference = %#v, %v", differences, err)
}
difference := differences[0]
if !difference.Left.Present || !difference.Right.Present || difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
t.Fatalf("public sequence change was concealed: %#v", difference)
}
if difference.Left.YAML != "- public scalar\n- 1\n" || difference.Right.YAML != "- public scalar\n- 2\n" {
t.Fatalf("public sequence snapshots = %#v", difference)
}
}
func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
type configuration struct {
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" },
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
delete(current.Dynamic, "entry")
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
return nil
},
}}
absent, err := configkit.Load(configkit.Bytes("removed-entry.yml", []byte("dynamic: {entry: {token: disposable-canary}}\n")), options)
if err != nil {
t.Fatal(err)
}
options.Definition.Resolve = nil
present, err := configkit.Load(configkit.Bytes("public-entry.yml", []byte("dynamic: {entry: public}\n")), options)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
left, right *configkit.Result[configuration]
leftPresent bool
}{
{name: "addition", left: absent, right: present},
{name: "removal", left: present, right: absent, leftPresent: true},
} {
t.Run(test.name, func(t *testing.T) {
differences, err := configkit.Diff(test.left, test.right)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/dynamic/entry" {
t.Fatalf("entry difference = %#v, %v", differences, err)
}
difference := differences[0]
if difference.Left.Present != test.leftPresent || difference.Right.Present == test.leftPresent ||
difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
t.Fatalf("public presence change was concealed: %#v", difference)
}
wantLeft, wantRight := "", "public\n"
if test.leftPresent {
wantLeft, wantRight = wantRight, wantLeft
}
if difference.Left.YAML != wantLeft || difference.Right.YAML != wantRight {
t.Fatalf("entry snapshots = %#v", difference)
}
})
}
}
func TestFacadeDiffReturnsDetachedCanonicalRecords(t *testing.T) {
left, err := configkit.Load[facadeDiffConfig](configkit.Bytes("left.yml", []byte("value: before\ncount: 0\nitems: [one, two]\ndynamic: {}\n")))
if err != nil {
t.Fatalf("load left: %v", err)
}
right, err := configkit.Load[facadeDiffConfig](configkit.Bytes("right.yml", []byte("value: after\ncount: 0\nitems: [one, three]\ndynamic:\n added: null\n")))
if err != nil {
t.Fatal(err)
}
differences, err := configkit.Diff(left, right)
if err != nil {
t.Fatal(err)
}
wantPaths := []string{"/dynamic/added", "/items", "/value"}
if len(differences) != len(wantPaths) {
t.Fatalf("differences = %#v", differences)
}
for index, difference := range differences {
if got := difference.Path.String(); got != wantPaths[index] {
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
}
}
if differences[0].Left.Present || !differences[0].Right.Present || differences[0].Right.YAML != "null\n" {
t.Errorf("null/absence snapshots = %#v / %#v", differences[0].Left, differences[0].Right)
}
if differences[1].Left.YAML != "- one\n- two\n" || differences[1].Right.YAML != "- one\n- three\n" {
t.Errorf("sequence snapshots = %#v / %#v", differences[1].Left, differences[1].Right)
}
left.Config.Value = "caller mutation"
left.Config.Items[0] = "caller mutation"
right.Config.Dynamic["added"] = "caller mutation"
differences[2].Left.YAML = "record mutation"
differences[2].Path = configkit.Keys("rewritten")
again, err := configkit.Diff(left, right)
if err != nil {
t.Fatal(err)
}
if again[2].Path.String() != "/value" || again[2].Left.YAML != "before\n" || again[1].Left.YAML != "- one\n- two\n" {
t.Fatalf("repeat diff changed = %#v", again)
}
explanation, ok := left.Explain(configkit.Keys("value"))
if !ok || explanation.Effective.YAML != "before\n" {
t.Fatalf("frozen explanation = %#v, %v", explanation, ok)
}
}
func TestFacadeDiffRejectsUnavailableResults(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
loaded, err := configkit.Load[config](configkit.Bytes("valid.yml", []byte("value: valid\n")))
if err != nil {
t.Fatal(err)
}
var nilResult *configkit.Result[config]
zeroResult := &configkit.Result[config]{Config: config{Value: "must not be inspected"}}
for name, left := range map[string]*configkit.Result[config]{"nil": nilResult, "zero": zeroResult} {
t.Run(name, func(t *testing.T) {
if records, err := configkit.Diff(left, loaded); err == nil || records != nil {
t.Fatalf("Diff = %#v, %v; want nil records and error", records, err)
}
})
}
if records, err := configkit.Diff(loaded, nilResult); err == nil || records != nil {
t.Fatalf("right nil Diff = %#v, %v", records, err)
}
}
func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
defaults := configkit.Options[config]{Definition: configkit.Definition[config]{
Defaults: func() config { return config{Count: 7} },
}}
defaulted, err := configkit.Load[config](configkit.NoSource(), defaults)
if err != nil {
t.Fatal(err)
}
explicit, err := configkit.Load[config](configkit.Bytes("explicit.yml", []byte("count: 7\n")))
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(defaulted, explicit); err != nil || len(differences) != 0 {
t.Fatalf("default/source difference = %#v, %v", differences, err)
}
zeroDefault, err := configkit.Load[config](configkit.NoSource(), configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { return config{} }}})
if err != nil {
t.Fatal(err)
}
explicitZero, err := configkit.Load[config](configkit.Bytes("explicit-zero.yml", []byte("count: 0\n")))
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(zeroDefault, explicitZero); err != nil || len(differences) != 0 {
t.Fatalf("explicit/defaulted zero difference = %#v, %v", differences, err)
}
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")
return nil
}}}
}
resolvedA, err := configkit.Load[config](configkit.Bytes("a.yml", []byte("{}\n")), resolve("a"))
if err != nil {
t.Fatal(err)
}
resolvedB, err := configkit.Load[config](configkit.Bytes("b.yml", []byte("{}\n")), resolve("b"))
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(resolvedA, resolvedB); err != nil || len(differences) != 0 {
t.Fatalf("equal resolved values = %#v, %v", differences, err)
}
}
func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
calls := 0
options := configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config {
calls++
return config{}
}}}
dir := t.TempDir()
paths := []string{filepath.Join(dir, "left.yml"), filepath.Join(dir, "right.yml")}
for index, contents := range []string{"value: left\n", "value: right\n"} {
if err := os.WriteFile(paths[index], []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
left, err := configkit.Load[config](configkit.File(paths[0]), options)
if err != nil {
t.Fatal(err)
}
right, err := configkit.Load[config](configkit.File(paths[1]), options)
if err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("defaults calls after loads = %d", calls)
}
for _, path := range paths {
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
}
differences, err := configkit.Diff(left, right)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/value" {
t.Fatalf("Diff after removing files = %#v, %v", differences, err)
}
if calls != 2 {
t.Fatalf("defaults reran during Diff: %d calls", calls)
}
}
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 {
return path.String() == "/secret"
}}}
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
if err != nil {
t.Fatal(err)
}
t.Setenv("FACADE_DIFF_SECRET", "credential-two")
right, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("right.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
if err != nil {
t.Fatal(err)
}
if differences, err := configkit.Diff(left, right); err != nil || len(differences) != 0 {
t.Fatalf("credential rotation = %#v, %v", differences, err)
}
t.Setenv("OTHER_FACADE_DIFF_SECRET", "other-credential")
otherReference, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("other.yml", []byte("secret: OTHER_FACADE_DIFF_SECRET\n")), secretOptions)
if err != nil {
t.Fatal(err)
}
differences, err := configkit.Diff(left, otherReference)
if err != nil || len(differences) != 1 || !differences[0].HiddenChanged || !differences[0].Left.Redacted || !differences[0].Right.Redacted {
t.Fatalf("reference change = %#v, %v", differences, err)
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "credential") {
t.Fatalf("credential leaked: %#v", differences)
}
invalidLeft, leftErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-left.yml", []byte("unknown: true\n")))
if invalidLeft != nil || leftErr == nil {
t.Fatalf("invalid left load = %#v, %v", invalidLeft, leftErr)
}
var loadError *configkit.LoadError
if !errors.As(leftErr, &loadError) {
t.Fatalf("left load error = %T, want *configkit.LoadError", leftErr)
}
invalidRight, rightErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-right.yml", []byte("unknown: true\n")))
if invalidRight != nil || rightErr == nil {
t.Fatalf("invalid right load = %#v, %v", invalidRight, rightErr)
}
if !errors.As(rightErr, &loadError) {
t.Fatalf("right load error = %T, want *configkit.LoadError", rightErr)
}
}

164
facade_equivalence_test.go Normal file
View File

@@ -0,0 +1,164 @@
package configkit_test
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type equivalentConfig struct {
Version uint64 `yaml:"version"`
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
Items []string `yaml:"items"`
Optional *string `yaml:"optional"`
}
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
hostPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte(yaml)}})
context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace}
for _, test := range []struct {
name string
source configkit.Source
context *configkit.FilesystemContext
kind configkit.SourceKind
}{
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
{"bytes", configkit.Bytes("memory", []byte(yaml)), 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,
"items": []any{}, "optional": nil,
}), context, configkit.SourceKindMapping},
} {
t.Run(test.name, func(t *testing.T) {
checks := 0
result, err := configkit.LoadLayered(configkit.Input{Source: test.source, Filesystem: test.context}, configkit.LayeredOptions[equivalentConfig]{
Definition: configkit.Definition[equivalentConfig]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
checks++
if !version.Present || version.Text != "1" {
t.Errorf("version = %#v", version)
}
return nil
},
}},
})
if err != nil {
t.Fatal(err)
}
want := equivalentConfig{Version: 1, Name: "retained", Items: []string{}}
if !reflect.DeepEqual(result.Config, want) || checks != 1 {
t.Fatalf("config = %#v, checks %d", result.Config, checks)
}
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") {
t.Errorf("%s input info = %#v, %v", field, info, ok)
}
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].ID != "source-1" || sources[0].UnderlayIndex != 0 {
t.Fatalf("sources = %#v", sources)
}
resource, ok := result.SourceContext("source-1")
if !ok || resource.BaseDir != "." || resource.FS == nil {
t.Fatalf("source context = %#v, %v", resource, ok)
}
})
}
}
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")},
"shared.yml": &fstest.MapFile{Data: []byte("name: retained\n")},
})
type config struct {
Name string `yaml:"name"`
}
left := configkit.Input{Source: namespace.File("left.yml"), Imports: true}
right := configkit.Input{Source: namespace.File("right.yml"), Imports: true}
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left, right}}},
})
if got := layeredErrorCode(t, err); got != configkit.CodeImportRepeated {
t.Fatalf("same-tier repeated import = %s", got)
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left}}, {Inputs: []configkit.Input{right}}},
})
if err != nil || result.Config.Name != "retained" || len(result.Sources()) != 4 {
t.Fatalf("cross-tier repeated import = %#v, %v", result, err)
}
reader := configkit.Reader("single-use", strings.NewReader("name: retained\n"))
_, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: reader}}}, {Inputs: []configkit.Input{{Source: reader}}}},
})
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("reader re-consumption = %s", got)
}
}
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
type config struct {
Secret string `yaml:"secret"`
}
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" }},
})
if err != nil || result.Config.Secret != "new-secret-canary" {
t.Fatalf("secret load = %#v, %v", result, err)
}
var effective, history bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&history, configkit.Keys("secret")); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String()+history.String(), "old-secret-canary") || strings.Contains(effective.String()+history.String(), "new-secret-canary") {
t.Fatalf("secret output leaked: %q %q", effective.String(), history.String())
}
_, 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]{Validate: func(config *config, validation *configkit.Validation) {
validation.Error(configkit.Keys("secret"), "invalid secret")
}},
})
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("validation error = %#v", err)
}
var diagnostics bytes.Buffer
if err := configkit.WriteDiagnostics(&diagnostics, loadError.Diagnostics()); err != nil {
t.Fatal(err)
}
if strings.Contains(diagnostics.String(), "secret-canary") {
t.Fatalf("diagnostic leaked secret: %q", diagnostics.String())
}
}
func layeredErrorCode(t *testing.T, err error) configkit.Code {
t.Helper()
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 {
t.Fatalf("load error = %#v", err)
}
return loadError.Diagnostics()[0].Code
}

142
facade_filesystem_test.go Normal file
View File

@@ -0,0 +1,142 @@
package configkit_test
import (
"embed"
"io/fs"
"reflect"
"strconv"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
//go:embed testdata/filesystem
var filesystemFixtures embed.FS
type filesystemFacadeConfig struct {
Items map[string]string `yaml:"items"`
}
func TestFacadeFilesystemSourceImportsOverlayAndReuse(t *testing.T) {
fixture, err := fs.Sub(filesystemFixtures, "testdata/filesystem")
if err != nil {
t.Fatal(err)
}
namespace := configkit.NewFilesystem("embedded", fixture)
copyOfNamespace := namespace
source := namespace.File("config/main.yml")
for range 2 {
result, err := configkit.Load(source, configkit.Options[filesystemFacadeConfig]{
Imports: true, Overlay: "testing", Filesystem: &configkit.FilesystemContext{FS: &copyOfNamespace},
})
if err != nil {
t.Fatal(err)
}
if want := (map[string]string{"main": "selected", "shared": "default"}); !reflect.DeepEqual(result.Config.Items, want) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
}
sources := result.Sources()
if len(sources) != 3 {
t.Fatalf("sources = %#v", sources)
}
for index, want := range []string{"config/main.yml", "shared.yml", "overlays/testing.yml"} {
if sources[index].ID != "source-"+strconv.Itoa(index+1) || sources[index].Path != want || sources[index].Kind != configkit.SourceKindFilesystem {
t.Errorf("source %d = %#v", index, sources[index])
}
if sources[index].Label != "embedded/"+want {
t.Errorf("source label = %q", sources[index].Label)
}
}
if sources[1].ImportedFrom == nil || sources[1].ImportedFrom.SourceID != sources[0].ID || sources[1].ImportedFrom.Line == 0 {
t.Fatalf("import location = %#v", sources[1].ImportedFrom)
}
}
}
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/shared.yml": &fstest.MapFile{Data: []byte("items: {value: first}\n")},
"root/overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {overlay: first}\n")},
})
second := configkit.NewFilesystem("same", fstest.MapFS{
"root/config/main.yml": &fstest.MapFile{Data: []byte("items: {value: second}\n")},
})
context := &configkit.FilesystemContext{Root: "root", FS: &first}
result, err := configkit.Load(first.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Imports: true, Overlay: "testing", Filesystem: context})
if err != nil || result.Config.Items["value"] != "first" || result.Config.Items["overlay"] != "first" {
t.Fatalf("contained load = %#v, %v", result, err)
}
result, err = configkit.Load[filesystemFacadeConfig](second.File("root/config/main.yml"))
if err != nil || result.Config.Items["value"] != "second" {
t.Fatalf("same-label independent namespace = %#v, %v", result, err)
}
checkCode(t, second.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: context}, configkit.CodeOptionsInvalid)
checkCode(t, first.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: &configkit.FilesystemContext{Root: "root/config", FS: &first}, Imports: true}, configkit.CodeImportOutsideRoot)
checkCode(t, configkit.File("testdata/filesystem/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: context}, configkit.CodeOptionsInvalid)
for _, input := range []struct {
name string
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},
} {
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}})
if err != nil || result.Config.Items["value"] != "first" {
t.Fatalf("synthetic import = %#v, %v", result, err)
}
if got := result.Sources(); len(got) != 2 || got[0].Kind != input.kind || got[0].Path != "" || got[1].Kind != configkit.SourceKindFilesystem {
t.Fatalf("sources = %#v", got)
}
})
}
checkCode(t, configkit.Bytes("memory", []byte("items: {}\n")), configkit.Options[filesystemFacadeConfig]{Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: ".", FS: &first}}, configkit.CodeImportOutsideRoot)
result, err = configkit.Load(configkit.Bytes("memory", []byte("items: {value: main}\n")), configkit.Options[filesystemFacadeConfig]{
Overlay: "testing", Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first},
})
if err != nil || result.Config.Items["value"] != "main" || result.Config.Items["overlay"] != "first" {
t.Fatalf("synthetic selected overlay = %#v, %v", result, err)
}
}
func TestFacadeSourceKindsIncludeHostAndCLI(t *testing.T) {
result, err := configkit.Load(configkit.File("testdata/filesystem/shared.yml"), configkit.Options[filesystemFacadeConfig]{
Overrides: []configkit.Override{{Path: configkit.Keys("items", "shared"), Value: "changed", Flag: "--shared"}},
})
if err != nil {
t.Fatal(err)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Kind != configkit.SourceKindFile || sources[0].Path == "" || sources[1].Kind != configkit.SourceKindCLI || sources[1].Path != "" {
t.Fatalf("host and CLI sources = %#v", sources)
}
}
func TestFacadeFilesystemConstructorDiagnostics(t *testing.T) {
var nilFilesystem fs.FS
var zero configkit.Filesystem
for _, source := range []configkit.Source{
zero.File("main.yml"),
configkit.NewFilesystem("", fstest.MapFS{}).File("main.yml"),
configkit.NewFilesystem("bad\nlabel", fstest.MapFS{}).File("main.yml"),
configkit.NewFilesystem("good", nilFilesystem).File("main.yml"),
configkit.NewFilesystem("good", fstest.MapFS{}).File("."),
configkit.NewFilesystem("good", fstest.MapFS{}).File("../outside.yml"),
} {
checkCode(t, source, configkit.Options[filesystemFacadeConfig]{}, configkit.CodeSourceInvalid)
}
}
func checkCode(t *testing.T, source configkit.Source, options configkit.Options[filesystemFacadeConfig], want configkit.Code) {
t.Helper()
_, err := configkit.Load(source, options)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
t.Fatalf("error = %#v, want %s", err, want)
}
}

284
facade_inheritance_test.go Normal file
View File

@@ -0,0 +1,284 @@
package configkit_test
import (
"bytes"
"io/fs"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type inheritedEntry struct {
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
Resource string `yaml:"resource"`
Settings map[string]map[string]string `yaml:"settings"`
}
type inheritedConfiguration struct {
Version string `yaml:"version"`
Entries map[string]inheritedEntry `yaml:"entries"`
}
func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
input := []byte(`entries:
parent:
enabled: false
count: 0
settings:
primary: {value: original}
secondary: {extends: primary}
child:
extends: parent
settings:
primary: {value: custom}
`)
defaultRan := false
result, err := configkit.Load(configkit.Bytes("config.yml", input), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
Resolve: func(config *inheritedConfiguration, resolution *configkit.Resolution) error {
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
t.Error("inherited false is not explicit")
}
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled"))
if !found || len(info.Origin.Inheritance) != 1 {
t.Errorf("resolver inherited origin = %#v, %v", info, found)
}
child := config.Entries["child"]
configkit.Default(resolution, configkit.Keys("entries", "child", "enabled"), &child.Enabled, true, "use fallback")
defaultRan = child.Enabled
config.Entries["child"] = child
return nil
},
}})
if err != nil {
t.Fatal(err)
}
child := result.Config.Entries["child"]
if defaultRan || child.Enabled || child.Count != 0 || child.Settings["secondary"]["value"] != "custom" {
t.Fatalf("inherited values = %#v, default ran %v", child, defaultRan)
}
path := configkit.Keys("entries", "child", "settings", "secondary", "value")
info, found := result.Metadata().Lookup(path)
if !found || !info.Explicit || len(info.Origin.Inheritance) != 1 ||
info.Origin.Inheritance[0].From.String() != "/entries/child/settings/primary/value" ||
info.Origin.Inheritance[0].To.String() != path.String() {
t.Fatalf("nested destination origin = %#v, %v", info, found)
}
info.Origin.Inheritance[0].Locations[0].SourceID = "mutated"
info, _ = result.Metadata().Lookup(path)
if info.Origin.Inheritance[0].Locations[0].SourceID == "mutated" {
t.Fatal("public inheritance record aliases inspection state")
}
if _, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "extends")); found {
t.Fatal("consumed directive remained effective")
}
control, found := result.Explain(configkit.Keys("entries", "child", "extends"))
if !found || control.Effective.Present || len(control.History) < 2 ||
control.History[len(control.History)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("consumed directive = %#v, %v", control, found)
}
explanation, found := result.Explain(path)
if !found || explanation.Effective.YAML != "custom\n" {
t.Fatalf("nested explanation = %#v, %v", explanation, found)
}
for _, change := range explanation.History {
if change.Origin.Operation == configkit.ChangeDecoding {
t.Fatalf("inherited value attributed to decoding: %#v", explanation.History)
}
}
}
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
"shared/parent.yml": &fstest.MapFile{Data: []byte("entries: {parent: {resource: icon.txt, count: 1}}\n")},
"shared/flags.yml": &fstest.MapFile{Data: []byte("entries: {parent: {enabled: false}}\n")},
"shared/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
"config/overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 2}}\n")},
})
result, err := configkit.LoadLayered(configkit.Input{Source: filesystem.File("config/main.yml")}, configkit.LayeredOptions[inheritedConfiguration]{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: filesystem.File("shared/parent.yml")}}},
{Inputs: []configkit.Input{{Source: filesystem.File("shared/flags.yml")}}},
},
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "count"), Value: 3, Flag: "--count"}},
})
if err != nil {
t.Fatal(err)
}
child := result.Config.Entries["child"]
if child.Count != 3 || child.Resource != "icon.txt" {
t.Fatalf("layered inheritance = %#v", child)
}
info, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "resource"))
if !found || len(info.Origin.Inheritance) != 1 || len(info.Origin.Locations) != 1 {
t.Fatalf("resource origin = %#v, %v", info, found)
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found || context.BaseDir != "shared" || context.FS == nil {
t.Fatalf("source context = %#v, %v", context, found)
}
data, err := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
if err != nil || string(data) != "parent-resource" {
t.Fatalf("source-relative resource = %q, %v", data, err)
}
count, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "count"))
if !found || count.Origin.Layer != configkit.LayerCLI || len(count.Origin.Inheritance) != 0 {
t.Fatalf("CLI replacement origin = %#v, %v", count, found)
}
var rendered bytes.Buffer
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "extends:") {
t.Fatalf("effective rendering = %q, %v", rendered.String(), err)
}
}
func TestFacadeInheritanceMappingMatchesBytes(t *testing.T) {
values := map[string]any{"entries": map[string]any{
"parent": map[string]any{"enabled": false, "count": 0},
"child": map[string]any{"extends": "parent"},
}}
mapped, err := configkit.Load[inheritedConfiguration](configkit.Mapping("mapping", values))
if err != nil {
t.Fatal(err)
}
fromYAML, err := configkit.Load[inheritedConfiguration](configkit.Bytes("config.yml", []byte("entries: {parent: {enabled: false, count: 0}, child: {extends: parent}}\n")))
if err != nil || !reflect.DeepEqual(mapped.Config, fromYAML.Config) {
t.Fatalf("mapping and bytes differ: %#v, %#v, %v", mapped, fromYAML, err)
}
}
func TestFacadeInheritanceProtectsVersionOwnership(t *testing.T) {
for _, test := range []struct {
name, input string
path configkit.Path
wantFailure bool
}{
{"inherited version", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "count"), true},
{"overridden version", "entries: {parent: {count: 1}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), true},
{"direct unchanged version", "entries: {parent: {enabled: false}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), false},
{"consumed checked directive", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "extends"), true},
{"independent top-level version", "version: v1\nentries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("version"), false},
} {
t.Run(test.name, func(t *testing.T) {
checks := struct{ version, defaults, resolution, validation int }{}
result, err := configkit.Load(configkit.Bytes("version.yml", []byte(test.input)), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error {
checks.version++
return nil
}},
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
}})
if test.wantFailure {
if result != nil || err == nil {
t.Fatalf("protected version = %#v, %v", result, err)
}
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceVersion ||
checks != (struct{ version, defaults, resolution, validation int }{version: 1}) {
t.Fatalf("version failure = %v, callbacks %#v", err, checks)
}
} else if err != nil || result == nil || checks != (struct{ version, defaults, resolution, validation int }{1, 1, 1, 1}) {
t.Fatalf("unchanged version = %#v, %v, callbacks %#v", result, err, checks)
}
})
}
}
func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
checks := struct{ defaults, resolution, validation int }{}
result, err := configkit.Load(configkit.Bytes("invalid.yml", []byte("entries: {child: {extends: PRIVATE-REFERENCE-CANARY}}\n")), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
}})
if result != nil || err == nil || checks != (struct{ defaults, resolution, validation int }{}) || strings.Contains(err.Error(), "PRIVATE-REFERENCE-CANARY") {
t.Fatalf("invalid inheritance = %#v, %v, callbacks %#v", result, err, checks)
}
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceMissing {
t.Fatalf("missing reference diagnostics = %v", err)
}
}
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
})
if err != nil || result.Config.Entries["child"].Count != 2 {
t.Fatalf("import and CLI reference = %#v, %v", result, err)
}
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("superseded reference history = %#v", history)
}
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
})
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
t.Fatalf("strict conflict before expansion = %v", err)
}
}
func TestFacadeInheritanceSelectedOverlayCancelsRelationship(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 7}, child: {extends: parent, enabled: false}}\n")},
"overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: null}}\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
Overlay: "test", Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem},
})
if err != nil || result.Config.Entries["child"].Count != 0 || result.Config.Entries["child"].Enabled {
if loadError, ok := err.(*configkit.LoadError); ok {
t.Fatalf("cancelled relationship = %#v, %v, diagnostics %#v", result, err, loadError.Diagnostics())
}
t.Fatalf("cancelled relationship = %#v, %v", result, err)
}
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("cancellation history = %#v", history)
}
}
func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
const canary = "INHERITED-RELEASE-TEST-CREDENTIAL"
t.Setenv("CONFIGKIT_INHERITED_TEST_SECRET", canary)
type entry struct {
Credential configkit.Secret `yaml:"credential"`
Plain string `yaml:"plain"`
}
type configuration struct {
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" },
}})
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
t.Fatalf("inherited credential = %#v, %v", result, err)
}
path := configkit.Keys("entries", "child", "credential")
explanation, found := result.Explain(path)
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
t.Fatalf("credential explanation = %#v, %v", explanation, found)
}
var rendered bytes.Buffer
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || !strings.Contains(rendered.String(), "visible") {
t.Fatalf("effective secret rendering = %q, %v", rendered.String(), err)
}
rendered.Reset()
if err := result.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) {
t.Fatalf("historical secret rendering = %q, %v", rendered.String(), err)
}
}

243
facade_layered_test.go Normal file
View File

@@ -0,0 +1,243 @@
package configkit_test
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type layeredFacadeConfig struct {
Settings map[string]string `yaml:"settings"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
List []string `yaml:"list"`
Optional *string `yaml:"optional"`
Derived string `yaml:"derived"`
}
func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
mainPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(mainPath, []byte("settings: {shared: main, secret: new-canary}\ncount: 0\nenabled: false\nlist: []\noptional: null\n"), 0o600); err != nil {
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")},
"defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")},
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")},
})
selected := configkit.NewFilesystem("bundle", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {shared: overlay}\n")},
})
checks := struct{ defaults, resolution, validation int }{}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[layeredFacadeConfig]{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{
{Source: lower.File("main.yml"), Imports: true},
{Source: configkit.Mapping("programmatic-low", map[string]any{"settings": map[string]any{"mapping": "retained"}, "count": 7, "enabled": true})},
{Source: lower.File("overlays/testing.yml")},
}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic-high", map[string]any{
"settings": map[string]any{"shared": "middle", "second": "retained"}, "list": []any{"middle"},
})}}},
},
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &selected},
Overrides: []configkit.Override{{Path: configkit.Keys("settings", "shared"), Value: "cli", Flag: "--shared"}},
Definition: configkit.Definition[layeredFacadeConfig]{
Defaults: func() layeredFacadeConfig { checks.defaults++; return layeredFacadeConfig{} },
Resolve: func(config *layeredFacadeConfig, resolution *configkit.Resolution) error {
checks.resolution++
if source, ok := resolution.Source("source-2"); !ok || source.Layer != configkit.LayerUnderlay || source.UnderlayIndex != 1 || source.Kind != configkit.SourceKindFilesystem {
t.Errorf("resolver source = %#v, %v", source, ok)
}
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" },
},
})
if err != nil {
t.Fatalf("layered load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
}
wantSettings := map[string]string{
"shared": "cli", "secret": "new-canary", "native": "retained", "imported": "retained",
"mapping": "retained", "from_lower_namespace": "retained", "second": "retained",
}
if !reflect.DeepEqual(result.Config.Settings, wantSettings) || result.Config.Count != 0 || result.Config.Enabled || result.Config.List == nil || len(result.Config.List) != 0 || result.Config.Optional != nil || result.Config.Derived != "retained" {
t.Fatalf("configuration = %#v", result.Config)
}
if checks != (struct{ defaults, resolution, validation int }{1, 1, 1}) {
t.Fatalf("lifecycle calls = %#v", checks)
}
sources := result.Sources()
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}
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 {
if source.ID != "source-"+strconv.Itoa(index+1) || source.Layer != wantLayers[index] || source.UnderlayIndex != wantIndexes[index] || source.Kind != wantKinds[index] {
t.Errorf("source %d = %#v", index, source)
}
}
if sources[4].Path != sources[6].Path || sources[4].Label != sources[6].Label {
t.Fatalf("namespace-collision fixture changed: %#v, %#v", sources[4], sources[6])
}
sources[1].Label = "changed"
if result.Sources()[1].Label != "bundle/main.yml" {
t.Fatal("source records alias retained metadata")
}
for _, test := range []struct {
path string
want configkit.Layer
}{
{"/settings/native", configkit.LayerUnderlay},
{"/settings/shared", configkit.LayerCLI},
{"/derived", configkit.LayerResolution},
} {
path, err := configkit.ParsePath(test.path)
if err != nil {
t.Fatal(err)
}
info, ok := result.Metadata().Lookup(path)
if !ok || info.Origin.Layer != test.want {
t.Errorf("%s metadata = %#v, %v", test.path, info, ok)
}
}
container, ok := result.Metadata().Lookup(configkit.Keys("settings"))
if !ok || len(container.Origin.Locations) < 4 {
t.Fatalf("composite mapping origin = %#v, %v", container, ok)
}
explanation, ok := result.Explain(configkit.Keys("settings", "shared"))
if !ok || len(explanation.History) < 5 || explanation.Origin.Layer != configkit.LayerCLI {
t.Fatalf("shared history = %#v, %v", explanation, ok)
}
var effective, rendered bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&rendered, configkit.Keys("settings", "secret")); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String()+rendered.String(), "old-canary") || strings.Contains(effective.String()+rendered.String(), "new-canary") || !strings.Contains(rendered.String(), "redacted") {
t.Fatalf("secret rendering leaked: effective %q, explanation %q", effective.String(), rendered.String())
}
}
func TestFacadeLayeredDisplacedDescendantRemainsInspectable(t *testing.T) {
type config struct {
Choice string `yaml:"choice"`
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"choice": "flat"})}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{
"choice": map[string]any{"nested": "historical"},
})}}}},
})
if err != nil || result.Config.Choice != "flat" {
t.Fatalf("replacement load = %#v, %v", result, err)
}
path := configkit.Keys("choice", "nested")
explanation, ok := result.Explain(path)
if !ok || explanation.Effective.Present || len(explanation.History) == 0 || explanation.History[0].Origin.Layer != configkit.LayerUnderlay {
t.Fatalf("deleted descendant = %#v, %v", explanation, ok)
}
}
func TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("value: selected\n")},
})
context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}
source := configkit.Bytes("main", []byte("value: base\n"))
type config struct {
Value string `yaml:"value"`
}
ordinary, err := configkit.Load(source, configkit.Options[config]{Overlay: "testing", Filesystem: context})
if err != nil {
t.Fatal(err)
}
layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[config]{Overlay: "testing", OverlayFilesystem: context})
if err != nil {
t.Fatal(err)
}
if ordinary.Config != layered.Config || !reflect.DeepEqual(ordinary.Sources(), layered.Sources()) {
t.Fatalf("ordinary = %#v, %#v; layered = %#v, %#v", ordinary.Config, ordinary.Sources(), layered.Config, layered.Sources())
}
var first, second bytes.Buffer
if err := ordinary.WriteEffective(&first); err != nil {
t.Fatal(err)
}
if err := layered.WriteEffective(&second); err != nil {
t.Fatal(err)
}
if first.String() != second.String() {
t.Fatalf("effective ordinary %q, layered %q", first.String(), second.String())
}
if ordinaryExplanation, ok := ordinary.Explain(configkit.Keys("value")); !ok {
t.Fatal("ordinary explanation missing")
} else if layeredExplanation, ok := layered.Explain(configkit.Keys("value")); !ok || !reflect.DeepEqual(ordinaryExplanation, layeredExplanation) {
t.Fatalf("explanations differ: %#v, %#v", ordinaryExplanation, layeredExplanation)
}
}
func TestFacadeLayeredOptionAndVersionFailures(t *testing.T) {
type config struct {
Version string `yaml:"version"`
}
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("{}\n")},
})
for _, test := range []struct {
name string
main configkit.Input
options []configkit.LayeredOptions[config]
want configkit.Code
}{
{"zero source", configkit.Input{}, nil, configkit.CodeSourceInvalid},
{"multiple options", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{}, {}}, configkit.CodeOptionsInvalid},
{"missing overlay context", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{Overlay: "testing"}}, configkit.CodeOptionsInvalid},
{"selected file already acquired", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("overlays/testing.yml")}}}},
}}, configkit.CodeOverlayRepeated},
{"underlay version", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{
Definition: configkit.Definition[config]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"version": "forbidden"})}}}},
}}, configkit.CodeUnderlayVersion},
} {
t.Run(test.name, func(t *testing.T) {
_, err := configkit.LoadLayered(test.main, test.options...)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.want {
t.Fatalf("error = %#v, want %s", err, test.want)
}
})
}
}
func TestFacadeLayeredExplicitHostOverlayWithoutMain(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "overlays"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "overlays", "testing.yml"), []byte("value: selected\n"), 0o600); err != nil {
t.Fatal(err)
}
type config struct {
Value string `yaml:"value"`
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: root},
})
if err != nil || result.Config.Value != "selected" || len(result.Sources()) != 1 || result.Sources()[0].Kind != configkit.SourceKindFile {
t.Fatalf("explicit host overlay = %#v, %v", result, err)
}
}

133
facade_mapping_test.go Normal file
View File

@@ -0,0 +1,133 @@
package configkit_test
import (
"math"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type mappingFacadeConfig struct {
Enabled bool `yaml:"enabled"`
Count uint64 `yaml:"count"`
Optional *string `yaml:"optional"`
Items []string `yaml:"items"`
Name string `yaml:"name"`
}
func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
items := []any{"one", "two"}
values := map[string]any{
"name": "original", "enabled": false, "count": ^uint64(0),
"optional": nil, "items": items,
}
source := configkit.Mapping("programmatic", values)
values["name"] = "mutated"
items[0] = "mutated"
for range 2 {
result, err := configkit.Load[mappingFacadeConfig](source)
if err != nil {
t.Fatal(err)
}
want := mappingFacadeConfig{Count: ^uint64(0), Items: []string{"one", "two"}, Name: "original"}
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 != "" {
t.Fatalf("mapping sources = %#v", sources)
}
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 0 {
t.Fatalf("mapping name origin = %#v, %v", info, ok)
}
fromYAML, err := configkit.Load[mappingFacadeConfig](configkit.Bytes("equivalent", []byte("enabled: false\ncount: 18446744073709551615\noptional: null\nitems: [one, two]\nname: original\n")))
if err != nil || !reflect.DeepEqual(result.Config, fromYAML.Config) {
t.Fatalf("equivalent YAML = %#v, %v", fromYAML, err)
}
}
}
func TestFacadeMappingNullEmptyAndVersion(t *testing.T) {
result, err := configkit.Load[mappingFacadeConfig](configkit.Mapping("empty", map[string]any{}))
if err != nil || len(result.Sources()) != 1 {
t.Fatalf("empty mapping = %#v, %v", result, err)
}
var nilSequence []any
var nilMap map[string]any
var checked string
result, err = configkit.Load(configkit.Mapping("nullable", map[string]any{
"items": nilSequence, "optional": nilMap, "enabled": false, "count": uint64(0),
}), configkit.Options[mappingFacadeConfig]{Definition: configkit.Definition[mappingFacadeConfig]{
Version: &configkit.VersionRule{Path: configkit.Keys("count"), Check: func(version configkit.Version, validation *configkit.Validation) error {
checked = version.Text
return nil
}},
}})
if err != nil || checked != "0" || result.Config.Items != nil || result.Config.Optional != nil {
t.Fatalf("null and zero mapping = %#v, checked %q, %v", result, checked, err)
}
info, ok := result.Metadata().Lookup(configkit.Keys("items"))
if !ok || !info.Null || !info.Explicit {
t.Fatalf("null metadata = %#v, %v", info, ok)
}
}
func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"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,
})
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
})
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 {
t.Fatalf("mapping import sources = %#v", sources)
}
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{}, configkit.CodeImportDisabled)
}
func TestFacadeMappingDeferredValueDiagnostics(t *testing.T) {
cycle := map[string]any{}
cycle["secret"] = cycle
for _, test := range []struct {
name string
source configkit.Source
want configkit.Code
path string
}{
{"nil root", configkit.Mapping("nil", nil), configkit.CodeSourceInvalid, ""},
{"bad label", configkit.Mapping("bad\nlabel", map[string]any{}), configkit.CodeSourceInvalid, ""},
{"unsupported", configkit.Mapping("bad", map[string]any{"secret": struct{ Value string }{"canary-value"}}), configkit.CodeSourceInvalid, "/secret"},
{"nonfinite", configkit.Mapping("bad", map[string]any{"count": math.NaN()}), configkit.CodeSourceInvalid, "/count"},
{"cycle", configkit.Mapping("bad", cycle), configkit.CodeSourceInvalid, "/secret"},
{"too many bytes", configkit.Mapping("bad", map[string]any{"name": strings.Repeat("x", 8<<20+1)}), configkit.CodeSourceLimitExceeded, "/name"},
} {
t.Run(test.name, func(t *testing.T) {
loadError := checkMappingCode(t, test.source, configkit.Options[mappingFacadeConfig]{}, test.want)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != test.path || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || strings.Contains(diagnostic.Message, "canary-value") {
t.Fatalf("diagnostic = %#v", diagnostic)
}
})
}
}
func checkMappingCode(t *testing.T, source configkit.Source, options configkit.Options[mappingFacadeConfig], want configkit.Code) *configkit.LoadError {
t.Helper()
_, err := configkit.Load(source, options)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
t.Fatalf("error = %#v, want %s", err, want)
}
return loadError
}

View File

@@ -0,0 +1,297 @@
package configkit_test
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type observedResourceFS struct {
files fstest.MapFS
opens int
closed bool
}
func (f *observedResourceFS) Open(name string) (fs.File, error) {
f.opens++
if f.closed {
return nil, fs.ErrClosed
}
return f.files.Open(name)
}
type resourceFacadeConfig struct {
Native string `yaml:"native"`
Shared string `yaml:"shared"`
Host string `yaml:"host"`
Selected string `yaml:"selected"`
}
func TestFacadeSourceContextsFollowOriginsWithoutQueryIO(t *testing.T) {
host := t.TempDir()
if err := os.WriteFile(filepath.Join(host, "main.yml"), []byte("host: host.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(host, "host.txt"), []byte("host-v1"), 0o600); err != nil {
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/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")},
"bundle/shared.txt": &fstest.MapFile{Data: []byte("shared-v1")},
"bundle/overlays/overlay.txt": &fstest.MapFile{Data: []byte("overlay-v1")},
}}
namespace := configkit.NewFilesystem("bundle", borrowed)
var resolverContext configkit.ResourceContext
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(host, "main.yml"))}, configkit.LayeredOptions[resourceFacadeConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: namespace.File("bundle/config/defaults.yml"), Imports: true,
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
}}}},
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle", FS: &namespace},
Definition: configkit.Definition[resourceFacadeConfig]{Resolve: func(config *resourceFacadeConfig, resolution *configkit.Resolution) error {
before := borrowed.opens
var ok bool
resolverContext, ok = resolution.SourceContext("source-2")
if !ok || resolverContext.BaseDir != "config" || resolverContext.FS == nil {
t.Errorf("resolver context = %#v, %v", resolverContext, ok)
}
if _, ok := resolution.SourceContext("missing"); ok || borrowed.opens != before {
t.Error("resolution context query performed I/O or guessed an unknown source")
}
return nil
}},
})
if err != nil {
t.Fatalf("load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
}
queriesBefore := borrowed.opens
for _, test := range []struct {
field, wantBase, wantData string
}{
{"native", "config", "icon-v1"},
{"shared", ".", "shared-v1"},
{"selected", "overlays", "overlay-v1"},
{"host", ".", "host-v1"},
} {
origin, ok := result.Metadata().Lookup(configkit.Keys(test.field))
if !ok || len(origin.Origin.Locations) != 1 {
t.Fatalf("%s origin = %#v, %v", test.field, origin, ok)
}
context, ok := result.SourceContext(origin.Origin.Locations[0].SourceID)
if !ok || context.FS == nil || context.BaseDir != test.wantBase {
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
}
if borrowed.opens != queriesBefore {
t.Fatal("result SourceContext query performed filesystem I/O")
}
resourceName := path.Join(context.BaseDir, map[string]string{
"native": result.Config.Native, "shared": result.Config.Shared,
"selected": result.Config.Selected, "host": result.Config.Host,
}[test.field])
data, err := fs.ReadFile(context.FS, resourceName)
if err != nil || string(data) != test.wantData {
t.Fatalf("%s resource = %q, %v", test.field, data, err)
}
queriesBefore = borrowed.opens
}
if _, ok := result.SourceContext(""); ok {
t.Fatal("empty source ID returned a context")
}
var nilResult *configkit.Result[resourceFacadeConfig]
if _, ok := nilResult.SourceContext("source-2"); ok {
t.Fatal("nil result returned a context")
}
if _, err := fs.ReadFile(resolverContext.FS, "../shared.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("native root escape = %v", err)
}
borrowed.files["bundle/config/icon.txt"].Data = []byte("icon-v2")
data, err := fs.ReadFile(resolverContext.FS, "config/icon.txt")
if err != nil || string(data) != "icon-v2" {
t.Fatalf("updated native resource = %q, %v", data, err)
}
borrowed.closed = true
if result.Config.Native != "icon.txt" {
t.Fatal("closing caller filesystem altered loaded configuration")
}
if _, err := fs.ReadFile(resolverContext.FS, "config/icon.txt"); !errors.Is(err, fs.ErrClosed) {
t.Fatalf("closed caller filesystem error = %v", err)
}
}
func TestFacadeHostContextSurvivesConfigurationDeletion(t *testing.T) {
root := t.TempDir()
main := filepath.Join(root, "main.yml")
resource := filepath.Join(root, "asset.txt")
if err := os.WriteFile(main, []byte("native: asset.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resource, []byte("first"), 0o600); err != nil {
t.Fatal(err)
}
result, err := configkit.Load[resourceFacadeConfig](configkit.File(main))
if err != nil {
t.Fatal(err)
}
context, ok := result.SourceContext("source-1")
if !ok || context.BaseDir != "." {
t.Fatalf("host context = %#v, %v", context, ok)
}
if err := os.Remove(main); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resource, []byte("second"), 0o600); err != nil {
t.Fatal(err)
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Native))
if err != nil || string(data) != "second" || result.Config.Native != "asset.txt" {
t.Fatalf("resource after source deletion = %q, %v", data, err)
}
if _, err := fs.ReadFile(context.FS, "../asset.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("invalid host path = %v", err)
}
if err := os.Remove(resource); err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(context.FS, "asset.txt"); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("missing later resource = %v", err)
}
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "escape")); err == nil {
if _, err := fs.ReadFile(context.FS, "escape"); !errors.Is(err, fs.ErrPermission) {
t.Fatalf("host symlink escape = %v", err)
}
}
}
func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon")},
})
context := &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle/config", FS: &namespace}
for _, test := range []struct {
name string
source configkit.Source
}{
{"bytes", configkit.Bytes("memory", []byte("native: icon.txt\n"))},
{"reader", configkit.Reader("stream", strings.NewReader("native: icon.txt\n"))},
{"mapping", configkit.Mapping("values", map[string]any{"native": "icon.txt"})},
} {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.Load(test.source, configkit.Options[resourceFacadeConfig]{Filesystem: context})
if err != nil {
t.Fatal(err)
}
resource, ok := result.SourceContext("source-1")
if !ok || resource.BaseDir != "config" {
t.Fatalf("synthetic context = %#v, %v", resource, ok)
}
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, result.Config.Native))
if err != nil || string(data) != "icon" {
t.Fatalf("synthetic resource = %q, %v", data, err)
}
})
}
result, err := configkit.Load[resourceFacadeConfig](configkit.Bytes("plain", []byte("native: none\n")))
if err != nil {
t.Fatal(err)
}
if _, ok := result.SourceContext("source-1"); ok {
t.Fatal("contextless byte source returned a context")
}
result, err = configkit.Load(configkit.NoSource(), configkit.Options[resourceFacadeConfig]{Overrides: []configkit.Override{{Path: configkit.Keys("native"), Value: "from-cli", Flag: "--native"}}})
if err != nil {
t.Fatal(err)
}
if _, ok := result.SourceContext("source-1"); ok {
t.Fatal("CLI source returned a context")
}
}
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
borrowed := &observedResourceFS{files: fstest.MapFS{
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
}}
namespace := configkit.NewFilesystem("bundle", borrowed)
input := configkit.Input{
Source: namespace.File("bundle/config/main.yml"), Imports: true,
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
}
load := func() *configkit.Result[map[string]any] {
t.Helper()
result, err := configkit.LoadLayered[map[string]any](input)
if err != nil {
t.Fatalf("load relative native imports: %v", err)
}
return result
}
result := load()
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
sources := result.Sources()
if len(sources) != len(wantPaths) {
t.Fatalf("sources = %#v", sources)
}
for index, source := range sources {
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
t.Errorf("source %d = %#v", index, source)
}
if index == 0 {
if source.ImportedFrom != nil {
t.Errorf("entry source has import location %#v", source.ImportedFrom)
}
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
}
}
for _, test := range []struct {
field, effectivePath, wantBase, wantResource, wantContents string
}{
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
} {
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
if !ok || len(info.Origin.Locations) != 1 {
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
}
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
}
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
if err != nil || string(contents) != test.wantContents {
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
}
}
sources[1].Destination = configkit.Keys("caller", "mutation")
for query := 0; query < 2; query++ {
fresh := result.Sources()
if fresh[1].Destination.String() != "/logical" {
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
}
}
second := load()
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
}
}

669
facade_sequence_test.go Normal file
View File

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

View File

@@ -0,0 +1,879 @@
package configkit_test
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
t.Parallel()
for _, grouped := range []bool{false, true} {
for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} {
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
}
first := "records: [{id: first, version: first-version}]\n"
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
var result *configkit.Result[map[string]any]
var err error
if grouped {
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: configkit.Bytes("first", []byte(first))}, {Source: configkit.Bytes("child", []byte(child))},
}}},
})
} else {
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{
"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)},
"child.yml": {Data: []byte(child)},
})
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
Definition: definition, Imports: true, SourceVersionMode: configkit.SourceVersionModeRequired,
})
}
if err != nil {
t.Fatal(err)
}
sources := result.Sources()
check := sources[len(sources)-1].VersionCheck
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
t.Fatalf("source-local evidence changed: %#v", check)
}
if sensitivePath != "" {
if !check.Redacted || check.Version.Text != "" {
t.Fatal("shifted version evidence was disclosed")
}
} else if check.Redacted || check.Version.Text != "PRIVATE-VERSION-CANARY" {
t.Fatal("unclassified version evidence was lost")
}
})
}
}
}
func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
t.Parallel()
parsed, err := configkit.ParsePath("/records/0/version")
if err != nil {
t.Fatal(err)
}
for _, grouped := range []bool{false, true} {
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
}
first := "records: [{id: first, version: first}]\n"
child := "records: [{id: child, version: child}]\n"
var result *configkit.Result[map[string]any]
if grouped {
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(child))}}}},
})
} else {
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)}, "child.yml": {Data: []byte(child)}})
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Imports: true})
}
if err != nil || result == nil {
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
}
want := 2
if grouped {
want = 1
}
if len(result.Config["records"].([]any)) != want {
t.Fatal("version path changed composition")
}
}
}
}
func TestFacadeItemMainKeepsDefaultVersionOwnership(t *testing.T) {
t.Parallel()
for _, layered := range []bool{false, true} {
for _, protected := range []bool{true, false} {
body, helper := "id: main\n", "version: imported-version\n"
if !protected {
body += "version: main-version\n"
helper = "other: {version: permitted}\n"
}
filesystem := configkit.NewFilesystem("item-main", fstest.MapFS{
"main.yml": {Data: []byte(body + "configkit: {imports: [{path: helper.yml}]}\n")}, "helper.yml": {Data: []byte(helper)},
})
checks := 0
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("records").Index(0).Key("version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
checks++
if version.Text != "main-version" {
t.Error("helper supplied main version")
}
return nil
},
}}
var err error
if layered {
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: filesystem.File("main.yml"), Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem}, configkit.LayeredOptions[map[string]any]{Definition: definition})
} else {
_, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem})
}
if protected {
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportVersion || loadError.Diagnostics()[0].Path.String() != "/records/0/version" || checks != 0 {
t.Fatalf("layered=%t ownership error = %v, checks=%d", layered, err, checks)
}
} else if err != nil || checks != 1 {
t.Fatalf("unrelated helper version rejected: %v, checks=%d", err, checks)
}
}
}
}
type sourceVersionModeReader struct {
*strings.Reader
reads int
}
func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
t.Parallel()
type header struct {
Version string `yaml:"version"`
}
type secretHeader struct {
Version string `yaml:"version" configkit:"secret"`
}
type configuration struct {
Meta map[string]header `yaml:"meta"`
Data struct {
Meta map[string]secretHeader `yaml:"meta"`
} `yaml:"data"`
}
path, err := configkit.ParsePath("/meta/0/version")
if err != nil {
t.Fatal(err)
}
for _, reverse := range []bool{false, true} {
groups := []configkit.Underlay{
{Inputs: []configkit.Input{{
Source: configkit.Bytes("mapping", []byte("meta: {\"0\": {version: PRIVATE-HEADER-CANARY}}\n")),
Destination: configkit.Keys("data"),
}}},
{Inputs: []configkit.Input{{
Source: configkit.Bytes("sequence", []byte("meta: [{version: another-header}]\n")),
Destination: configkit.Keys("data"),
}}},
}
if reverse {
groups[0], groups[1] = groups[1], groups[0]
}
loaded, err := configkit.LoadLayered(configkit.Input{
Source: configkit.Bytes("main", []byte("meta: {\"0\": {version: main}}\ndata: {meta: null}\n")),
}, configkit.LayeredOptions[configuration]{
Definition: configkit.Definition[configuration]{Version: &configkit.VersionRule{
Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil },
}},
SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: groups,
})
if err != nil {
t.Fatal(err)
}
if loaded.Config.Meta["0"].Version != "main" || loaded.Config.Data.Meta != nil {
t.Fatalf("reverse=%v: final payload changed: %#v", reverse, loaded.Config)
}
foundMapping := false
for _, source := range loaded.Sources() {
if source.Label != "mapping" {
continue
}
foundMapping = true
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
}
}
if !foundMapping {
t.Fatal("missing mapping source record")
}
}
}
func TestFacadeSourceVersionModesPreserveImportOptIn(t *testing.T) {
t.Parallel()
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
for _, underlay := range []bool{false, true} {
bad := configkit.Bytes("disabled-imports", []byte("version: accepted\nconfigkit: {imports: [{path: missing.yml}]}\nvalue: visible\n"))
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
}}
var err error
var resultPresent bool
wantSource := "source-1"
if underlay {
loaded, loadErr := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: main\n"))}, configkit.LayeredOptions[map[string]any]{
Definition: definition, SourceVersionMode: mode,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: bad}}}},
})
err, resultPresent, wantSource = loadErr, loaded != nil, "source-2"
} else {
loaded, loadErr := configkit.Load(bad, configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: mode})
err, resultPresent = loadErr, loaded != nil
}
var failure *configkit.LoadError
if resultPresent || !errors.As(err, &failure) {
t.Errorf("mode=%v underlay=%v: import opt-in did not fail safely", mode, underlay)
continue
}
diagnostics := failure.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportDisabled || diagnostics[0].Path.String() != "/configkit" ||
diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != wantSource {
t.Errorf("mode=%v underlay=%v: diagnostics=%#v", mode, underlay, diagnostics)
}
}
}
}
func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, main, underlay string
path configkit.Path
}{
{"missing member", "meta: {version: main}\n", "{}\n", configkit.Keys("meta", "version")},
{"scalar obstruction", "meta: {version: main}\n", "meta: false\n", configkit.Keys("meta", "version")},
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
} {
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
var callbackEvidence *configkit.SourceVersionCheck
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
SourceVersionMode: mode,
Definition: configkit.Definition[map[string]any]{
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
source, found := resolution.Source("source-2")
if !found {
t.Fatal("missing callback source")
}
callbackEvidence = source.VersionCheck
return nil
},
},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
}}}},
})
var evidence *configkit.SourceVersionCheck
if mode == configkit.SourceVersionModeRequired {
var failure *configkit.LoadError
if loaded != nil || !errors.As(err, &failure) {
t.Fatalf("%s: required omission did not fail", test.name)
}
diagnostics := failure.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
}
evidence = failure.Sources()[1].VersionCheck
} else {
if err != nil {
t.Fatalf("%s: %v", test.name, err)
}
evidence = loaded.Sources()[1].VersionCheck
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
t.Errorf("%s: callback path is incomplete", test.name)
}
}
if evidence == nil || evidence.Version.Present || evidence.Path.String() != test.path.String() ||
evidence.Location == nil || evidence.Location.SourceID != "source-2" || evidence.Location.Line != 1 {
t.Errorf("%s mode=%v: absence evidence=%#v", test.name, mode, evidence)
}
}
}
}
func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
mode configkit.SourceVersionMode
control string
wantCode configkit.Code
}{
{"optional omission", configkit.SourceVersionModeIfPresent, "base", ""},
{"required omission", configkit.SourceVersionModeRequired, "base", configkit.CodeVersionError},
{"invalid control", configkit.SourceVersionModeIfPresent, "true", configkit.CodeInheritanceInvalid},
} {
t.Run(test.name, func(t *testing.T) {
main := configkit.Bytes("main", []byte("{}\n"))
if test.mode == configkit.SourceVersionModeRequired {
main = configkit.NoSource()
}
checks := 0
path := configkit.Keys("entries", "child", "extends", "version")
loaded, err := configkit.LoadLayered(configkit.Input{Source: main, Destination: configkit.Keys("main")}, configkit.LayeredOptions[map[string]any]{
SourceVersionMode: test.mode,
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
checks++
if version.Present {
t.Error("checker received a nonexistent declaration")
}
return nil
},
}},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("underlay", []byte("entries:\n base: {value: good}\n child: {extends: "+test.control+"}\n")), Destination: configkit.Keys("under"),
}}}},
})
if checks != 1 {
t.Errorf("checker ran %d times, want absent main only", checks)
}
if test.wantCode != "" {
var failure *configkit.LoadError
if loaded != nil || !errors.As(err, &failure) {
t.Fatal("invalid input did not fail safely")
}
if diagnostics := failure.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode {
t.Fatalf("diagnostics=%#v", diagnostics)
}
return
}
if err != nil {
t.Fatal(err)
}
child := loaded.Config["under"].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
if child["value"] != "good" {
t.Fatal("valid sibling inheritance was not applied")
}
check := loaded.Sources()[1].VersionCheck
if check == nil || check.Version.Present || check.Path.String() != path.String() {
t.Fatal("optional omission evidence was not retained")
}
})
}
}
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
reader.reads++
return reader.Reader.Read(data)
}
func TestFacadeSourceVersionModesAcrossOrdinaryAndLayeredLoads(t *testing.T) {
t.Parallel()
var checked []configkit.Version
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("meta", "version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
checked = append(checked, version)
return nil
},
}}
ordinary, err := configkit.Load(configkit.Bytes("ordinary", []byte("meta: {version: ordinary-v}\n")), configkit.Options[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
if err != nil || ordinary == nil {
t.Fatalf("ordinary enabled load = %#v, %v", ordinary, err)
}
if got := ordinary.Config["meta"].(map[string]any)["version"]; got != "ordinary-v" {
t.Fatalf("main declaration was not retained: %#v", ordinary.Config)
}
directory := t.TempDir()
mainPath := filepath.Join(directory, "main.yml")
if err := os.WriteFile(mainPath, []byte("meta: {version: main-v}\n"), 0o600); err != nil {
t.Fatal(err)
}
layered, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Mapping("underlay", map[string]any{
"meta": map[string]any{"version": "underlay-v"}, "fallback": "present",
}),
Destination: configkit.Keys("defaults"),
}}}},
})
if err != nil || layered == nil {
t.Fatalf("layered enabled load = %#v, %v", layered, err)
}
defaults, ok := layered.Config["defaults"].(map[string]any)
if !ok || defaults["fallback"] != "present" {
t.Fatalf("layered underlay payload = %#v", layered.Config["defaults"])
}
if meta, ok := defaults["meta"].(map[string]any); !ok || len(meta) != 0 {
t.Fatalf("accepted underlay declaration was not consumed: %#v", defaults["meta"])
}
if len(checked) != 3 {
t.Fatalf("checker calls across separate mode uses = %#v", checked)
}
for _, want := range []string{"ordinary-v", "main-v", "underlay-v"} {
found := false
for _, version := range checked {
found = found || version.Present && version.Text == want
}
if !found {
t.Errorf("checked versions %#v do not include %q", checked, want)
}
}
}
func TestFacadeSourceVersionModeZeroPreservesDefaultBehavior(t *testing.T) {
t.Parallel()
checks := 0
result, err := configkit.Load(configkit.Bytes("default", []byte("version: v1\n")), configkit.Options[map[string]any]{
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
checks++
if version != (configkit.Version{Present: true, Text: "v1"}) {
t.Errorf("default version = %#v", version)
}
return nil
},
}},
})
if err != nil || result == nil || result.Config["version"] != "v1" || checks != 1 {
t.Fatalf("zero-mode load = %#v, %v; checks = %d", result, err, checks)
}
}
func TestFacadeSourceVersionModeValidationPrecedesSourceReads(t *testing.T) {
t.Parallel()
validDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
}}
for _, test := range []struct {
name string
mode configkit.SourceVersionMode
def configkit.Definition[map[string]any]
layered bool
}{
{name: "unknown ordinary mode", mode: configkit.SourceVersionMode(255), def: validDefinition},
{name: "ordinary mode requires checker", mode: configkit.SourceVersionModeIfPresent},
{name: "unknown layered mode", mode: configkit.SourceVersionMode(255), def: validDefinition, layered: true},
{name: "layered mode requires checker", mode: configkit.SourceVersionModeRequired, layered: true},
} {
t.Run(test.name, func(t *testing.T) {
reader := &sourceVersionModeReader{Reader: strings.NewReader("version: v1\n")}
source := configkit.Reader("version.yml", reader)
var err error
if test.layered {
_, err = configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[map[string]any]{
Definition: test.def, SourceVersionMode: test.mode,
})
} else {
_, err = configkit.Load(source, configkit.Options[map[string]any]{
Definition: test.def, SourceVersionMode: test.mode,
})
}
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
t.Fatalf("invalid mode error = %#v", err)
}
if reader.reads != 0 {
t.Fatalf("invalid options read source %d times", reader.reads)
}
})
}
}
func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T) {
t.Parallel()
checks := 0
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
checks++
if version.Present {
t.Errorf("missing declaration reached checker as %#v", version)
}
return nil
},
}}
source := configkit.Bytes("reused", []byte("value: retained\n"))
ifPresent, err := configkit.Load(source, configkit.Options[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
if err != nil || ifPresent == nil || checks != 1 {
t.Fatalf("if-present load = %#v, %v; checks = %d", ifPresent, err, checks)
}
required, err := configkit.Load(source, configkit.Options[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
})
var loadError *configkit.LoadError
if required != nil || !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeVersionError {
t.Fatalf("required load = %#v, %v", required, err)
}
if checks != 1 {
t.Fatalf("required absence reached checker: calls = %d", checks)
}
}
func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
t.Parallel()
const canary = "PRIVATE-SOURCE-VERSION-CANARY"
directory := t.TempDir()
path := filepath.Join(directory, "main.yml")
if err := os.WriteFile(path, []byte("version: "+canary+"\nvalue: visible\n"), 0o600); err != nil {
t.Fatal(err)
}
var callbackSource configkit.SourceInfo
var callbackContext configkit.ResourceContext
definition := configkit.Definition[map[string]any]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
var found bool
callbackSource, found = resolution.Source("source-1")
if !found {
t.Fatal("resolution callback could not query the acquired source")
}
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck)
}
callbackContext, found = resolution.SourceContext("source-1")
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
t.Fatalf("callback source context = %#v, found=%v", callbackContext, found)
}
return nil
},
}
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Destination: configkit.Keys("service")}, configkit.LayeredOptions[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
if err != nil || loaded == nil {
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
}
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck)
}
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
callbackSource.VersionCheck.Version.Text = canary
callbackSource.VersionCheck.Location.Line = 98
finalSources := loaded.Sources()
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
t.Fatalf("final sources = %#v", finalSources)
}
check := finalSources[0].VersionCheck
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
t.Fatalf("final classified evidence = %#v", check)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if callbackContext.FS == nil || callbackContext.BaseDir != "." {
t.Fatalf("resource metadata changed after removing the source: %#v", callbackContext)
}
check.Path = configkit.Keys("mutated")
check.Location.Line = 99
check.Redacted = false
check.Version.Text = "mutated"
repeated := loaded.Sources()
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" ||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted ||
repeated[0].VersionCheck.Version.Text != "" {
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
}
publicText := "PUBLIC-SOURCE-VERSION"
var publicCallback configkit.SourceInfo
public, err := configkit.Load(configkit.Bytes("public", []byte("version: "+publicText+"\n")), configkit.Options[map[string]any]{
SourceVersionMode: configkit.SourceVersionModeIfPresent,
Definition: configkit.Definition[map[string]any]{
Version: definition.Version,
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
publicCallback, _ = resolution.Source("source-1")
return nil
},
},
})
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
}
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
t.Fatalf("unclassified evidence = %#v", got)
}
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" {
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck)
}
}
func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.T) {
t.Parallel()
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
}}
t.Run("early import acquisition failure", func(t *testing.T) {
const canary = "EARLY-ACQUISITION-SOURCE-VERSION-CANARY"
checked := 0
earlyDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
}}
path := filepath.Join(t.TempDir(), "main.yml")
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Imports: true}, configkit.LayeredOptions[map[string]any]{
Definition: earlyDefinition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
var failure *configkit.LoadError
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
t.Fatalf("early acquisition failure sources = %#v, error %v", failure, err)
}
if checked != 1 {
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
}
check := failure.Sources()[0].VersionCheck
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
}
if strings.Contains(err.Error(), canary) {
t.Fatalf("early acquisition failure disclosed source-version canary: %v", err)
}
})
_, err := configkit.Load(configkit.Bytes("malformed", []byte("version: true\n")), configkit.Options[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("malformed version error = %v", err)
}
sources := loadError.Sources()
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted ||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" {
t.Fatalf("failed-load source evidence = %#v", sources)
}
sources[0].VersionCheck.Path = configkit.Keys("mutated-error")
sources[0].VersionCheck.Version.Text = "mutated"
sources[0].VersionCheck.Location.Line = 99
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
t.Fatalf("LoadError source evidence was aliased: %#v", again)
}
for _, test := range []struct {
name string
def configkit.Definition[map[string]any]
canary string
}{
{
name: "resolver failure",
canary: "RESOLVER-SOURCE-VERSION-CANARY",
def: configkit.Definition[map[string]any]{Version: definition.Version, Resolve: func(*map[string]any, *configkit.Resolution) error {
return errors.New("resolver failed")
}},
},
{
name: "validator failure",
canary: "VALIDATOR-SOURCE-VERSION-CANARY",
def: configkit.Definition[map[string]any]{Version: definition.Version, Validate: func(_ *map[string]any, validation *configkit.Validation) {
validation.Error(configkit.Keys("value"), "invalid value")
}},
},
} {
t.Run(test.name, func(t *testing.T) {
_, err := configkit.Load(configkit.Bytes("late-failure", []byte("version: "+test.canary+"\nvalue: configured\n")), configkit.Options[map[string]any]{
Definition: test.def, SourceVersionMode: configkit.SourceVersionModeIfPresent,
})
var failure *configkit.LoadError
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
t.Fatalf("late failure sources = %#v, error %v", failure, err)
}
if strings.Contains(err.Error(), test.canary) {
t.Fatalf("late failure disclosed source-version canary: %v", err)
}
check := failure.Sources()[0].VersionCheck
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
}
})
}
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil {
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
}
withCLI, err := configkit.Load(configkit.Bytes("main", []byte("version: 8\nvalue: old\n")), configkit.Options[map[string]any]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
Overrides: []configkit.Override{{Path: configkit.Keys("value"), Value: "new", Flag: "--value"}},
})
if err != nil || withCLI == nil {
t.Fatalf("enabled load with CLI source = %#v, %v", withCLI, err)
}
sawCLI := false
for _, source := range withCLI.Sources() {
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
}
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
}
if !sawCLI {
t.Fatalf("CLI source record missing: %#v", withCLI.Sources())
}
}
func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
t.Parallel()
var checked []configkit.Version
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: 17\n"))}, configkit.LayeredOptions[map[string]any]{
SourceVersionMode: configkit.SourceVersionModeIfPresent,
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
checked = append(checked, version)
return nil
},
}},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: configkit.Bytes("absent", []byte("value: visible\n")), Destination: configkit.Keys("absent")},
{Source: configkit.Bytes("null", []byte("version: null\nvalue: visible\n")), Destination: configkit.Keys("nullable")},
}}},
})
if err != nil || loaded == nil {
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
}
if len(checked) != 2 || !checked[0].Present || checked[0].Text != "17" ||
!checked[1].Present || !checked[1].Null {
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
}
sources := loaded.Sources()
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil {
t.Fatalf("source version evidence = %#v", sources)
}
absent := sources[1].VersionCheck
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
t.Fatalf("optional omission evidence = %#v", absent)
}
null := sources[2].VersionCheck
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
t.Fatalf("null declaration evidence = %#v", null)
}
}
func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *testing.T) {
type serviceConfig struct {
Version uint64 `yaml:"version"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Nullable *string `yaml:"nullable"`
Revision string `yaml:"revision"`
Defaulted string `yaml:"defaulted"`
Resolved string `yaml:"resolved"`
Credential configkit.Secret `yaml:"credential"`
}
type configuration struct {
Service serviceConfig `yaml:"service"`
}
t.Setenv("CONFIGKIT_LIFECYCLE_SECRET", "lifecycle-secret-value")
var checked []configkit.Version
var validationCalls int
definition := configkit.Definition[configuration]{
Defaults: func() configuration {
return configuration{Service: serviceConfig{Defaulted: "from defaults"}}
},
Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, _ *configkit.Validation) error {
checked = append(checked, version)
return nil
},
},
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Service.Resolved = "resolved once"
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
return nil
},
Validate: func(current *configuration, validation *configkit.Validation) {
validationCalls++
if current.Service.Version != 9007199254740993 || current.Service.Count != 0 || current.Service.Enabled ||
current.Service.Nullable != nil || current.Service.Revision != "ordinary-payload" || current.Service.Defaulted != "from defaults" ||
current.Service.Resolved != "resolved once" || current.Service.Credential.Value() != "lifecycle-secret-value" {
validation.Error(configkit.Keys("service"), "assembled values did not survive source declaration processing")
}
},
}
load := func(header string) *configkit.Result[configuration] {
t.Helper()
result, err := configkit.LoadLayered(configkit.Input{
Source: configkit.Bytes("main", []byte("version: 9007199254740993\n")), Destination: configkit.Keys("service"),
}, configkit.LayeredOptions[configuration]{
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("underlay", []byte("version: "+header+"\ncount: 0\nenabled: false\nnullable: null\nrevision: ordinary-payload\ncredential: CONFIGKIT_LIFECYCLE_SECRET\n")),
Destination: configkit.Keys("service"),
}}}},
})
if err != nil || result == nil {
var failure *configkit.LoadError
var diagnostics []configkit.Diagnostic
if errors.As(err, &failure) {
diagnostics = failure.Diagnostics()
}
t.Fatalf("LoadLayered(%q) = %#v, %v; diagnostics %#v", header, result, err, diagnostics)
}
return result
}
first := load("underlay-release-a")
second := load("underlay-release-b")
if len(checked) != 4 || checked[0].Text != "9007199254740993" || checked[1].Text != "underlay-release-a" ||
checked[2].Text != "9007199254740993" || checked[3].Text != "underlay-release-b" {
t.Fatalf("source declarations checked = %#v", checked)
}
if validationCalls != 2 || first.Config.Service != second.Config.Service {
t.Fatalf("final configs or validation count = %#v, %#v, %d", first.Config, second.Config, validationCalls)
}
if differences, err := configkit.Diff(first, second); err != nil || len(differences) != 0 {
t.Fatalf("different consumed declarations created effective differences: %#v, %v", differences, err)
}
var firstEffective, secondEffective bytes.Buffer
if err := first.WriteEffective(&firstEffective); err != nil {
t.Fatal(err)
}
if err := second.WriteEffective(&secondEffective); err != nil {
t.Fatal(err)
}
if firstEffective.String() != secondEffective.String() || strings.Contains(firstEffective.String(), "underlay-release") {
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
}
sourcesA, sourcesB := first.Sources(), second.Sources()
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil ||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" {
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
}
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
if !found {
t.Fatal("retained main version missing from effective explanation")
}
underlayID := sourcesA[1].ID
for _, location := range versionExplanation.Origin.Locations {
if location.SourceID == underlayID {
t.Fatalf("consumed underlay declaration entered effective origin: %#v", versionExplanation.Origin)
}
}
for _, change := range versionExplanation.History {
for _, location := range change.Origin.Locations {
if location.SourceID == underlayID {
t.Fatalf("consumed underlay declaration entered effective history: %#v", versionExplanation.History)
}
}
}
}

View File

@@ -0,0 +1,107 @@
package configkit_test
import (
"reflect"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
for _, later := range []bool{false, true} {
name := "first tier"
if later {
name = "later tier"
}
t.Run(name, func(t *testing.T) {
bundle := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: items.yml}]}\n")},
"items.yml": &fstest.MapFile{Data: []byte("items: [{name: first, nested: [false]}]\n")},
})
tiers := []configkit.Underlay{}
if later {
tiers = append(tiers, configkit.Underlay{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"other": true})}}})
}
tiers = append(tiers, configkit.Underlay{Inputs: []configkit.Input{{Source: bundle.File("main.yml"), Imports: true}}})
paths := []string{"/items", "/items/0", "/items/0/name", "/items/0/nested", "/items/0/nested/0"}
entry := map[string]configkit.Origin{}
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"main": true})}, configkit.LayeredOptions[map[string]any]{
Underlays: tiers,
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
for _, pointer := range paths {
path, _ := configkit.ParsePath(pointer)
info, ok := resolution.InputInfo(path)
if !ok {
t.Errorf("missing resolver input %s", pointer)
}
entry[pointer] = info.Origin
}
return nil
}},
})
if err != nil {
t.Fatal(err)
}
var sourceID string
for _, source := range result.Sources() {
if source.Path == "items.yml" {
sourceID = source.ID
}
}
if sourceID == "" {
t.Fatal("missing imported source")
}
for _, pointer := range paths {
path, _ := configkit.ParsePath(pointer)
info, ok := result.Metadata().Lookup(path)
if !ok || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != sourceID {
t.Errorf("%s origin = %+v", pointer, info.Origin)
}
if !reflect.DeepEqual(entry[pointer], info.Origin) {
t.Errorf("%s resolver origin differs", pointer)
}
explanation, ok := result.Explain(path)
if !ok || !reflect.DeepEqual(explanation.Origin, info.Origin) {
t.Errorf("%s explanation origin differs", pointer)
}
history := result.Metadata().History(path)
if len(history) == 0 || !reflect.DeepEqual(history[len(history)-1].Origin, info.Origin) {
t.Errorf("%s history = %+v", pointer, history)
}
}
})
}
}
func TestUnderlayHistoryRetainsLaterMappingContributors(t *testing.T) {
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: configkit.Mapping("one", map[string]any{"x": 1})}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("two", map[string]any{"x": map[string]any{}})}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("three", map[string]any{"x": map[string]any{"y": 2}})}}},
}})
if err != nil {
t.Fatal(err)
}
path := configkit.Keys("x")
info, _ := result.Metadata().Lookup(path)
wantOrigin := configkit.Origin{Layer: configkit.LayerUnderlay, Locations: []configkit.Location{{SourceID: "source-2"}, {SourceID: "source-3"}}}
if !reflect.DeepEqual(info.Origin, wantOrigin) {
t.Fatalf("effective origin = %+v, want %+v", info.Origin, wantOrigin)
}
history := result.Metadata().History(path)
if len(history) != 3 || !reflect.DeepEqual(history[len(history)-1].Origin, info.Origin) {
t.Fatalf("history = %+v, effective origin = %+v", history, info.Origin)
}
explanation, ok := result.Explain(path)
if !ok || !reflect.DeepEqual(explanation.Origin, info.Origin) {
t.Fatal("explanation and metadata disagree")
}
if len(explanation.History) != 3 {
t.Fatalf("explanation history = %+v", explanation.History)
}
last := explanation.History[len(explanation.History)-1].Origin
if last.Layer != configkit.LayerUnderlay || len(last.Locations) != 1 || last.Locations[0].SourceID != "source-3" {
t.Fatalf("last explanation origin = %+v", last)
}
}

10
go.mod
View File

@@ -1,5 +1,13 @@
module gitea.maximumdirect.net/eric/ConfigKit module gitea.maximumdirect.net/eric/ConfigKit
go 1.25.0 go 1.26.0
require go.yaml.in/yaml/v4 v4.0.0-rc.6 require go.yaml.in/yaml/v4 v4.0.0-rc.6
require (
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
github.com/yuin/goldmark/v2 v2.1.6 // indirect
golang.org/x/mod v0.40.0 // indirect
)
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit

6
go.sum
View File

@@ -1,2 +1,8 @@
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=

View File

@@ -39,7 +39,8 @@ type Explanation struct {
} }
// Explain returns safe effective and chronological values for a known final or // Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false. // historical path. Structural inheritance appears between source applications
// and typed reconciliation. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) { func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil { if r == nil {
return Explanation{}, false return Explanation{}, false

View File

@@ -0,0 +1,119 @@
package engine
import (
"strconv"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func BenchmarkComposeWideMappings(b *testing.B) {
for _, width := range []int{128, 256, 512} {
b.Run(strconv.Itoa(width), func(b *testing.B) {
left := benchmarkCatalog("source-1", 0, width)
right := benchmarkCatalog("source-2", width, width)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, diagnostics := composeDocuments(left, right); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
})
}
}
func BenchmarkOverlayRecursiveMappings(b *testing.B) {
for _, width := range []int{128, 256, 512} {
b.Run(strconv.Itoa(width), func(b *testing.B) {
base, diagnostics := composeDocuments(benchmarkCatalog("source-1", 0, width))
if len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
overlay := benchmarkCatalog("source-2", width/2, width)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, diagnostics := overlayDocuments(base, overlay); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
})
}
}
func BenchmarkOverlayManyApplications(b *testing.B) {
for _, count := range []int{32, 64, 128} {
inputs := make([]*document.Node, count)
for index := range inputs {
inputs[index] = benchmarkCatalog("source-2", index, 1)
}
benchmarkOverlayApplications(b, count, inputs)
}
}
func BenchmarkOverlayRepeatedReplacement(b *testing.B) {
for _, count := range []int{32, 64, 128} {
inputs := make([]*document.Node, count)
for index := range inputs {
location := document.Location{SourceID: "source-2"}
value := &document.Node{Kind: document.String, Text: "replacement", Location: location}
if index%2 == 0 {
value = &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "enabled", Value: &document.Node{Kind: document.Boolean, Text: "false", Location: location},
}}}
}
inputs[index] = &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "catalog", Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "item-0", Value: value,
}}},
}}}
}
benchmarkOverlayApplications(b, count, inputs)
}
}
func benchmarkOverlayApplications(b *testing.B, count int, inputs []*document.Node) {
base, diagnostics := composeDocuments(benchmarkCatalog("source-1", 0, 1))
if len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
for _, mode := range []string{"owned", "copy-preserving"} {
b.Run(mode+"/"+strconv.Itoa(count), func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if mode == "owned" {
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
for _, input := range inputs {
if diagnostics := assembly.applyOverlay(input, nil, nil, LayerOverlay); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
continue
}
current := base
for _, input := range inputs {
current, diagnostics = overlayDocuments(current, input)
if len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
}
})
}
}
func benchmarkCatalog(source string, first, width int) *document.Node {
location := document.Location{SourceID: source}
entries := make([]document.Entry, width)
for index := range entries {
entries[index] = document.Entry{
Key: "item-" + strconv.Itoa(first+index),
Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{Key: "enabled", Value: &document.Node{Kind: document.Boolean, Text: "true", Location: location}}}},
}
}
return &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "catalog", Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: entries},
}}}
}

View File

@@ -6,30 +6,64 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// precedence layers. Claims remain ordered by source and declaration traversal; // precedence layers. Claims remain ordered by source and declaration traversal;
// origins retain every source that contributes to a composite mapping. // origins retain every source that contributes to a composite mapping.
type composedDocument struct { type composedDocument struct {
root *document.Node root *document.Node
claims []compositionClaim acquisition []acquisitionScope
origins map[string]Origin claims []compositionClaim
overlays []overlayRecord sequenceRanges []sequenceRange
applications []historyApplication origins map[string]Origin
inputBytes int64 overlays []overlayRecord
inputNodes int applications []historyApplication
inputBytes int64
inputNodes int
}
type sequenceHistoryMode uint8
const (
sequenceHistoryReplace sequenceHistoryMode = iota + 1
sequenceHistoryAppend
)
type sequenceHistoryContribution struct {
path Path
mode sequenceHistoryMode
offset int
length int
}
// assemblyAccumulator owns one mutable working document. Acquired trees held by
// history applications are separate from its root and remain unchanged.
type assemblyAccumulator struct {
current *composedDocument
index mappingIndex
}
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
return &assemblyAccumulator{current: owned}
} }
// cloneComposedDocument detaches mutable composition and provenance state; // cloneComposedDocument detaches mutable composition and provenance state;
// history application trees are immutable and retain their existing sharing. // history application trees are immutable and retain their existing sharing.
func cloneComposedDocument(base *composedDocument) *composedDocument { func cloneComposedDocument(base *composedDocument) *composedDocument {
if base == nil {
return nil
}
return &composedDocument{ return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), acquisition: cloneAcquisitionScopes(base.acquisition),
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications), applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes, inputBytes: base.inputBytes, inputNodes: base.inputNodes,
} }
} }
type compositionClaim struct { type compositionClaim struct {
path Path node *document.Node // borrowed immutable contribution, not the assembly tree
kind document.Kind path Path
location Location kind document.Kind
location Location
sourceIndex int
} }
type compositionClaims struct { type compositionClaims struct {
@@ -37,16 +71,32 @@ type compositionClaims struct {
byPath map[string][]compositionClaim byPath map[string][]compositionClaim
} }
// sequenceRange records where one source's sequence items were assigned in the
// composed sequence at path. End is exclusive.
type sequenceRange struct {
sourceIndex int
path Path
start int
end int
}
// composeDocuments recursively joins disjoint mapping entries. Source order // composeDocuments recursively joins disjoint mapping entries. Source order
// controls only output traversal and diagnostics: every non-mapping overlap is // controls only output traversal and diagnostics: every non-mapping overlap is
// rejected before a result is assembled. // rejected before a result is assembled.
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) { func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
return composeDocumentsWithPolicy(sequencePolicy{}, inputs...)
}
// composeDocumentsWithPolicy composes one strict tier using the registered
// sequence rules when policy contains registrations. The ordinary adapter above
// deliberately preserves the existing unregistered behavior for current callers.
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
if len(inputs) == 0 { if len(inputs) == 0 {
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)} return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
} }
collected := compositionClaims{byPath: make(map[string][]compositionClaim)} collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
for _, input := range inputs { for sourceIndex, input := range inputs {
if input == nil || input.Kind != document.Mapping { if input == nil || input.Kind != document.Mapping {
var location *Location var location *Location
if input != nil { if input != nil {
@@ -55,38 +105,92 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
} }
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)} return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
} }
collected.append(input, Path{}) collected.append(input, Path{}, sourceIndex)
} }
checked := make(map[string]struct{}, len(collected.byPath))
for _, claim := range collected.ordered { for _, claim := range collected.ordered {
claims := collected.byPath[claim.path.String()] pointer := claim.path.String()
if len(claims) < 2 || allMappingClaims(claims) { if _, seen := checked[pointer]; seen {
continue
}
checked[pointer] = struct{}{}
claims := collected.byPath[pointer]
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
continue continue
} }
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)} return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
} }
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy)
if len(diagnostics) != 0 {
return nil, diagnostics
}
root := document.Clone(inputs[0]) root := document.Clone(inputs[0])
for _, input := range inputs[1:] { index := new(mappingIndex)
mergeDisjointMappings(root, input) for sourceIndex, input := range inputs[1:] {
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
} }
applications := make([]historyApplication, 0, len(inputs)) applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs { seenSequences := make(map[string]bool)
applications = append(applications, historyApplication{layer: LayerBase, value: input}) rangesBySource := make([][]sequenceRange, len(inputs))
for _, assigned := range ranges {
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
} }
for sourceIndex, input := range inputs {
application := historyApplication{layer: LayerBase, value: input}
for _, assigned := range rangesBySource[sourceIndex] {
pointer := assigned.path.String()
mode := sequenceHistoryAppend
if !seenSequences[pointer] {
mode = sequenceHistoryReplace
}
seenSequences[pointer] = true
application.sequences = append(application.sequences, sequenceHistoryContribution{
path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start,
})
}
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
applications = append(applications, application)
}
origins := projectedCompositionOrigins(inputs, ranges)
completeCompositionOrigins(root, Path{}, origins)
return &composedDocument{ return &composedDocument{
root: root, root: root,
claims: cloneCompositionClaims(collected.ordered), claims: cloneCompositionClaims(collected.ordered),
origins: compositionOrigins(collected.ordered), sequenceRanges: cloneSequenceRanges(ranges),
applications: applications, origins: origins,
applications: applications,
}, nil }, nil
} }
func (c *compositionClaims) append(node *document.Node, path Path) { // Conflict claims treat sequences atomically, but inspection needs origins for
// every descendant. Preserve composite mapping claims while filling those gaps.
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
pointer := path.String()
if _, present := origins[pointer]; !present {
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}}
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
completeCompositionOrigins(entry.Value, path.Key(entry.Key), origins)
}
case document.Sequence:
for index, child := range node.Sequence {
completeCompositionOrigins(child, path.Index(index), origins)
}
}
}
func (c *compositionClaims) append(node *document.Node, path Path, sourceIndex int) {
claim := compositionClaim{ claim := compositionClaim{
path: path.clone(), node: node,
kind: node.Kind, path: path.clone(),
location: publicLocation(node.Location), kind: node.Kind,
location: publicLocation(node.Location),
sourceIndex: sourceIndex,
} }
c.ordered = append(c.ordered, claim) c.ordered = append(c.ordered, claim)
pointer := path.String() pointer := path.String()
@@ -96,10 +200,146 @@ func (c *compositionClaims) append(node *document.Node, path Path) {
return return
} }
for _, entry := range node.Mapping { for _, entry := range node.Mapping {
c.append(entry.Value, path.Key(entry.Key)) c.append(entry.Value, path.Key(entry.Key), sourceIndex)
} }
} }
func allSequenceClaims(claims []compositionClaim) bool {
for _, claim := range claims {
if claim.kind != document.Sequence {
return false
}
}
return true
}
type validatedSequence struct {
items []*document.Node
}
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
itemsByPath := make(map[string]map[int]validatedSequence)
identitiesByPath := make(map[string]map[string]Location)
lengthsByPath := make(map[string]int)
var ranges []sequenceRange
for _, claim := range claims {
if claim.kind != document.Sequence {
continue
}
identityField, registered := policy.identityFields[claim.path.String()]
if !registered {
continue
}
node := claim.node
pathKey := claim.path.String()
identities := identitiesByPath[pathKey]
if identities == nil {
identities = make(map[string]Location, len(node.Sequence))
identitiesByPath[pathKey] = identities
}
start := lengthsByPath[pathKey]
contribution := validatedSequence{items: node.Sequence}
for itemIndex, item := range node.Sequence {
itemPath := claim.path.Index(start + itemIndex)
if item == nil || item.Kind != document.Mapping {
location := (*Location)(nil)
if item != nil {
converted := publicLocation(item.Location)
location = &converted
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
}}
}
fieldIndex := documentEntryIndex(item.Mapping, identityField)
if fieldIndex < 0 {
location := publicLocation(item.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath, Message: "registered sequence item is missing its identity field",
Primary: &location,
}}
}
field := item.Mapping[fieldIndex].Value
if field == nil || field.Kind != document.String {
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
if field != nil {
location = publicLocation(field.Location)
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath.Key(identityField), Message: "registered sequence identity must be a string",
Primary: &location,
}}
}
if first, duplicate := identities[field.Text]; duplicate {
current := publicLocation(field.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeConflict, Severity: SeverityError,
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
Primary: &first, Related: []Location{current},
}}
}
identities[field.Text] = publicLocation(field.Location)
}
if itemsByPath[pathKey] == nil {
itemsByPath[pathKey] = make(map[int]validatedSequence)
}
itemsByPath[pathKey][claim.sourceIndex] = contribution
lengthsByPath[pathKey] = start + len(node.Sequence)
ranges = append(ranges, sequenceRange{
sourceIndex: claim.sourceIndex, path: claim.path.clone(), start: start, end: start + len(node.Sequence),
})
}
return itemsByPath, ranges, nil
}
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
for _, incomingEntry := range incoming.Mapping {
entryPath := path.Key(incomingEntry.Key)
position := index.find(target, incomingEntry.Key)
if position < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
index.append(target, cloned)
continue
}
current := target.Mapping[position].Value
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
contribution := validated[entryPath.String()][sourceIndex]
if contribution.items != nil {
for _, item := range contribution.items {
current.Sequence = append(current.Sequence, document.Clone(item))
}
}
continue
}
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
}
}
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
return
}
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
}
func (p sequencePolicy) registered(path Path) bool {
_, ok := p.identityFields[path.String()]
return ok
}
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
cloned := make([]sequenceRange, len(ranges))
for i, assigned := range ranges {
cloned[i] = assigned
cloned[i].path = assigned.path.clone()
}
return cloned
}
func allMappingClaims(claims []compositionClaim) bool { func allMappingClaims(claims []compositionClaim) bool {
for _, claim := range claims { for _, claim := range claims {
if claim.kind != document.Mapping { if claim.kind != document.Mapping {
@@ -109,18 +349,18 @@ func allMappingClaims(claims []compositionClaim) bool {
return true return true
} }
func mergeDisjointMappings(target, incoming *document.Node) { func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
for _, incomingEntry := range incoming.Mapping { for _, incomingEntry := range incoming.Mapping {
index := documentEntryIndex(target.Mapping, incomingEntry.Key) position := index.find(target, incomingEntry.Key)
if index < 0 { if position < 0 {
cloned := incomingEntry cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value) cloned.Value = document.Clone(incomingEntry.Value)
target.Mapping = append(target.Mapping, cloned) index.append(target, cloned)
continue continue
} }
// Claim validation guarantees that the only remaining overlap is a // Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries. // mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[index].Value, incomingEntry.Value) mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
} }
} }
@@ -133,18 +373,64 @@ func documentEntryIndex(entries []document.Entry, key string) int {
return -1 return -1
} }
func compositionOrigins(claims []compositionClaim) map[string]Origin { func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
origins := make(map[string]Origin, len(claims)) origins := make(map[string]Origin)
for _, claim := range claims { rangeBySourcePath := make(map[sequenceRangeKey]int, len(ranges))
pointer := claim.path.String() for index, assigned := range ranges {
origin := origins[pointer] rangeBySourcePath[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = index
origin.Layer = LayerBase }
origin.Locations = append(origin.Locations, claim.location) for sourceIndex, input := range inputs {
origins[pointer] = origin collectProjectedCompositionOrigins(input, Path{}, Path{}, sourceIndex, ranges, rangeBySourcePath, origins)
} }
return origins return origins
} }
type sequenceRangeKey struct {
sourceIndex int
path string
}
type sequenceRangeIndex map[sequenceRangeKey]sequenceRange
func indexSequenceRanges(ranges []sequenceRange) sequenceRangeIndex {
if len(ranges) == 0 {
return nil
}
index := make(sequenceRangeIndex, len(ranges))
for _, assigned := range ranges {
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
}
return index
}
// Keep local paths for matching Stage 2 ranges and effective paths for emitted
// evidence. The paths are transformed segment by segment; pointer strings are
// only used as map keys after projection is complete.
func collectProjectedCompositionOrigins(node *document.Node, localPath, effectivePath Path, sourceIndex int, ranges []sequenceRange, rangeBySourcePath map[sequenceRangeKey]int, origins map[string]Origin) {
pointer := effectivePath.String()
origin := origins[pointer]
origin.Layer = LayerBase
origin.Locations = append(origin.Locations, publicLocation(node.Location))
origins[pointer] = origin
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectProjectedCompositionOrigins(entry.Value, localPath.Key(entry.Key), effectivePath.Key(entry.Key), sourceIndex, ranges, rangeBySourcePath, origins)
}
case document.Sequence:
start := 0
if rangeIndex, ok := rangeBySourcePath[sequenceRangeKey{sourceIndex: sourceIndex, path: localPath.String()}]; ok {
start = ranges[rangeIndex].start
}
for index, child := range node.Sequence {
localChild := localPath.Index(index)
effectiveChild := effectivePath.Index(start + index)
collectProjectedCompositionOrigins(child, localChild, effectiveChild, sourceIndex, ranges, rangeBySourcePath, origins)
}
}
}
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) { func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
for pointer, origin := range origins { for pointer, origin := range origins {
if _, present := decoded.presence[pointer]; present { if _, present := decoded.presence[pointer]; present {

View File

@@ -1,12 +1,60 @@
package engine package engine
import ( import (
"fmt"
"reflect" "reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
policy := sequencePolicy{identityFields: make(map[string]string)}
var inputs []*document.Node
for source := 0; source < 3; source++ {
var text strings.Builder
for collection := 0; collection < 128; collection++ {
key := fmt.Sprintf("records%d", collection)
policy.identityFields[Keys(key).String()] = "id"
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
}
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
}
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
working := &document.Node{Kind: document.Mapping}
for _, application := range composed.applications {
applyHistoryApplication(&working, application, new(mappingIndex))
}
if !configurationValuesEqual(working, composed.root) {
t.Fatal("wide registered collections replay differs from assembly")
}
for collection := 0; collection < 128; collection++ {
path := Keys(fmt.Sprintf("records%d", collection)).Index(2).Key("id")
origin := composed.origins[path.String()]
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
t.Fatalf("shifted origin at %s = %#v", path.String(), origin)
}
local, ok := historyApplicationLocalPath(composed.applications[2], path)
if !ok || local.String() != Keys(fmt.Sprintf("records%d", collection)).Index(0).Key("id").String() {
t.Fatal("wide local path projection failed")
}
}
clone := cloneComposedDocument(composed)
for _, original := range composed.applications[0].sequences {
pointer := original.path.String()
copy := clone.applications[0].sequencesByPath[pointer]
copy.path.segments[0].key = "changed"
if composed.applications[0].sequencesByPath[pointer].path.String() != pointer {
t.Fatal("history path index aliases cloned state")
}
break
}
}
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) { func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
base := parseCompositionDocument(t, "source-1", `services: base := parseCompositionDocument(t, "source-1", `services:
api: api:
@@ -116,6 +164,31 @@ func TestComposeDocumentsReportsAllClaimantsAtExactLocations(t *testing.T) {
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"}) assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
} }
func TestComposeDocumentsKeepsFirstConflictAndAppendedEntryOrder(t *testing.T) {
first := parseCompositionDocument(t, "source-1", "catalog:\n first: 1\n shared: one\n later: one\n")
second := parseCompositionDocument(t, "source-2", "catalog:\n second: 2\n shared: two\n later: two\n")
third := parseCompositionDocument(t, "source-3", "catalog:\n third: 3\n shared: three\n")
_, diagnostics := composeDocuments(first, second, third)
assertCompositionConflict(t, diagnostics, "/catalog/shared", []string{"source-1", "source-2", "source-3"})
composed, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "catalog:\n first: 1\n"),
parseCompositionDocument(t, "source-2", "catalog:\n second: 2\n"),
parseCompositionDocument(t, "source-3", "catalog:\n third: 3\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
}
entries := composed.root.Mapping[0].Value.Mapping
got := make([]string, len(entries))
for index, entry := range entries {
got[index] = entry.Key
}
if want := []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
t.Fatalf("catalog keys = %#v, want %#v", got, want)
}
}
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) { func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
empty, diagnostics := composeDocuments( empty, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "{}\n"), parseCompositionDocument(t, "source-1", "{}\n"),
@@ -165,6 +238,268 @@ func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
} }
} }
func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T) {
first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
}
records, ok := documentAtPath(composed.root, Keys("records"))
if !ok || records.Kind != document.Sequence || len(records.Sequence) != 3 {
t.Fatalf("records = %#v, want three appended records", records)
}
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
{sourceIndex: 0, path: Keys("records"), start: 0, end: 2},
{sourceIndex: 1, path: Keys("records"), start: 2, end: 3},
}) {
t.Fatalf("sequence ranges = %#v", got)
}
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
t.Fatal("registered composition mutated an input")
}
composed.root.Mapping[0].Value.Sequence[0].Mapping[1].Value.Text = "changed"
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
t.Fatal("composed sequence aliases an input")
}
}
func TestComposeDocumentsProjectsSequenceOriginsToAssignedIndexes(t *testing.T) {
first := parseCompositionDocument(t, "source-1", `mounted:
records:
- id: first
name: first-name
escaped/~:
"7":
- id: escaped-first
name: escaped-first-name
`)
second := parseCompositionDocument(t, "source-2", `mounted:
records:
- id: second
name: second-name
escaped/~:
"7":
- id: escaped-second
name: escaped-second-name
`)
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{
Keys("mounted", "records").String(): "id",
Keys("escaped/~", "7").String(): "id",
}}
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
}
for _, test := range []struct {
path Path
want Location
}{
{path: Keys("mounted", "records").Index(1).Key("name"), want: Location{SourceID: "source-2", Line: 4, Column: 13}},
{path: Keys("escaped/~", "7").Index(1).Key("name"), want: Location{SourceID: "source-2", Line: 8, Column: 13}},
} {
path, want := test.path, test.want
origin, ok := composed.origins[path.String()]
if !ok || !reflect.DeepEqual(origin.Locations, []Location{want}) {
t.Errorf("origin at %s = %#v, want source-specific location %#v", path, origin, want)
}
}
for _, test := range []struct {
path Path
wantSources []string
}{
{path: Keys("mounted", "records"), wantSources: []string{"source-1", "source-2"}},
{path: Keys("escaped/~", "7"), wantSources: []string{"source-1", "source-2"}},
} {
path, wantSources := test.path, test.wantSources
origin := composed.origins[path.String()]
gotSources := make([]string, len(origin.Locations))
for index, location := range origin.Locations {
gotSources[index] = location.SourceID
}
if !reflect.DeepEqual(gotSources, wantSources) {
t.Errorf("container origin at %s = %#v, want contributors %#v", path, gotSources, wantSources)
}
}
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
t.Fatal("composition projection mutated an input")
}
copy := cloneComposedDocument(composed)
copy.origins[Keys("mounted", "records").Index(1).Key("name").String()].Locations[0].SourceID = "changed"
for index := range copy.claims {
if copy.claims[index].path.String() == "/mounted/records" {
copy.claims[index].path.segments[0].key = "changed"
break
}
}
copy.sequenceRanges[0].path.segments[0].key = "changed"
if got := composed.origins[Keys("mounted", "records").Index(1).Key("name").String()].Locations[0].SourceID; got != "source-2" {
t.Fatalf("copied origin mutation changed source evidence: %q", got)
}
foundClaim := false
for _, claim := range composed.claims {
if claim.path.String() == "/mounted/records" {
foundClaim = true
break
}
}
if !foundClaim {
t.Fatal("copied claim mutation changed source path")
}
if got := composed.sequenceRanges[0].path.String(); got != "/mounted/records" {
t.Fatalf("copied range mutation changed assigned path: %q", got)
}
}
func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
tests := []struct {
name string
body string
path string
wantCode Code
}{
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0", wantCode: CodeComposeInvalid},
{name: "null identity", body: "records: [{id: null}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "numeric identity", body: "records: [{id: 12}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "boolean identity", body: "records: [{id: false}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", test.body),
)
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostics = %#v, want %s at %s", diagnostics, test.wantCode, test.path)
}
if diagnostics[0].Primary == nil {
t.Fatal("malformed sequence diagnostic has no source location")
}
})
}
}
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" {
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics)
}
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)
}
}
func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
tests := []struct {
name string
first string
second string
within bool
}{
{name: "within identical bodies", first: "records: [{id: '', value: same}, {id: '', value: same}]\n", within: true},
{name: "within disjoint bodies", first: "records: [{id: same, left: 1}, {id: same, right: 2}]\n", within: true},
{name: "across identical bodies", first: "records: [{id: same, value: same}]\n", second: "records: [{id: same, value: same}]\n"},
{name: "across disjoint bodies", first: "records: [{id: same, left: 1}]\n", second: "records: [{id: same, right: 2}]\n"},
{name: "empty identity across lists", first: "records: [{id: ''}]\n", second: "records: [{id: ''}]\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
inputs := []*document.Node{parseCompositionDocument(t, "source-1", test.first)}
if !test.within {
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
}
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs...,
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
}
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("duplicate locations = %#v", diagnostics[0])
}
for _, secret := range []string{"same", "left", "right", "value"} {
if strings.Contains(diagnostics[0].Message, secret) {
t.Fatalf("diagnostic leaks %q: %q", secret, diagnostics[0].Message)
}
}
})
}
}
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
}}
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
not-a-sequence: null
catalog:
records: [{id: present}]
`))
if len(diagnostics) != 0 {
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
}
if len(composed.sequenceRanges) != 1 || composed.sequenceRanges[0].path.String() != "/catalog/records" {
t.Fatalf("sequence ranges = %#v, want only the reached descendant sequence", composed.sequenceRanges)
}
composed, diagnostics = composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", "records: []\n"),
parseCompositionDocument(t, "source-2", "records: []\n"),
)
if len(diagnostics) != 0 || len(composed.root.Mapping[0].Value.Sequence) != 0 {
t.Fatalf("empty registered sequences = (%#v, %#v)", composed, diagnostics)
}
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
{sourceIndex: 0, path: Keys("records"), start: 0, end: 0},
{sourceIndex: 1, path: Keys("records"), start: 0, end: 0},
}) {
t.Fatalf("empty sequence ranges = %#v", got)
}
}
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/registered": "id"}},
parseCompositionDocument(t, "source-1", "unregistered: [one]\n"),
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
)
assertCompositionConflict(t, diagnostics, "/unregistered", []string{"source-1", "source-2"})
}
func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testing.T) {
tests := []struct {
name string
first string
second string
}{
{name: "mapping and sequence", first: "records: {id: one}\n", second: "records: [{id: two}]\n"},
{name: "null and sequence", first: "records: null\n", second: "records: [{id: two}]\n"},
{name: "scalar and sequence", first: "records: value\n", second: "records: [{id: two}]\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", test.first),
parseCompositionDocument(t, "source-2", test.second),
)
assertCompositionConflict(t, diagnostics, "/records", []string{"source-1", "source-2"})
})
}
}
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node { func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
t.Helper() t.Helper()
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits()) parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())

View File

@@ -0,0 +1,197 @@
package engine
import (
"errors"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
var (
errInvalidDestination = errors.New("configkit: destination must contain only non-reserved mapping keys")
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
)
type placementMode uint8
const (
placementModeMapping placementMode = iota
placementModeSequenceItem
)
// PlacementModeFromFacade preserves root-owned numeric values until preflight
// can reject unsupported modes.
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
// PlacementModeForFacade converts private source evidence to the root-owned mode.
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
// InputWithPlacement copies the root-owned mode into a private engine input.
func InputWithPlacement(input Input, value uint8) Input {
input.placementMode = placementMode(value)
return input
}
// OptionsWithPlacement copies root-owned controls into private engine options.
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
options.destination = destination.clone()
options.placementMode = placementMode(value)
return options
}
func normalizeDestination(path Path) (Path, error) {
if !path.valid() {
return Path{}, errInvalidDestination
}
segments := make([]pathSegment, 0, len(path.segments))
for _, segment := range path.segments {
if segment.kind != pathSegmentKey && segment.kind != pathSegmentParsed {
return Path{}, errInvalidDestination
}
if segment.key == "configkit" || segment.key == "extends" {
return Path{}, errInvalidDestination
}
segments = append(segments, pathSegment{kind: pathSegmentKey, key: segment.key})
}
return Path{segments: segments}, nil
}
// appendDestination combines validated mapping-key paths with owned segments.
func appendDestination(parent, relative Path) Path {
segments := clonePathSegments(parent.segments, len(relative.segments))
segments = append(segments, relative.segments...)
return Path{segments: segments}
}
func invalidDestinationDiagnostic(destination Path) Diagnostic {
return Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: destination.clone(),
Message: "input destination must contain only non-reserved mapping keys",
}
}
// placeDocument checks all per-document bounds before allocating destination
// wrappers. Aggregate accounting remains the caller's responsibility.
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
}
// placeDocumentWithMode places a source mapping beneath its literal destination.
// Sequence-item placement adds one sequence containing the complete mapping.
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
normalized, err := normalizeDestination(destination)
if err != nil {
return Path{}, nil, err
}
if mode != placementModeMapping && mode != placementModeSequenceItem {
return Path{}, nil, errInvalidDestination
}
if mode == placementModeSequenceItem && len(normalized.segments) == 0 {
return Path{}, nil, errInvalidDestination
}
if body == nil {
return normalized, nil, nil
}
if mode == placementModeSequenceItem && body.Kind != document.Mapping {
return normalized, nil, errInvalidDestination
}
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
return normalized, nil, errDestinationLimit
}
placed := body
if mode == placementModeSequenceItem {
placed = &document.Node{
Kind: document.Sequence, Location: sourceRoot,
Sequence: []*document.Node{placed},
}
}
for index := len(normalized.segments) - 1; index >= 0; index-- {
key := normalized.segments[index].key
placed = &document.Node{
Kind: document.Mapping,
Location: sourceRoot,
Mapping: []document.Entry{{
Key: key,
KeyLocation: sourceRoot,
ValueLocation: sourceRoot,
Value: placed,
}},
}
}
return normalized, placed, nil
}
// destinationWithinLimits expects a normalized mapping-key destination.
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
}
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
if body == nil {
return true
}
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
return false
}
segments := len(destination.segments)
sequenceNodes := 0
sequenceDepth := 0
if mode == placementModeSequenceItem {
if segments == 0 {
return false
}
sequenceNodes = 1
sequenceDepth = 1
}
if segments > maxDepth-sequenceDepth || segments > (maxNodes-sequenceNodes)/2 {
return false
}
wrapperNodes := destinationWrapperNodeCount(destination) + sequenceNodes
for _, segment := range destination.segments {
if segment.kind != pathSegmentKey {
return false
}
}
if documentDepth(body) > maxDepth-segments-sequenceDepth {
return false
}
return countInputNodes(body) <= maxNodes-wrapperNodes
}
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int {
count := destinationWrapperNodeCount(destination)
if mode == placementModeSequenceItem {
count++
}
return count
}
func destinationWrapperNodeCount(destination Path) int {
return 2 * len(destination.segments)
}
func documentDepth(node *document.Node) int {
if node == nil {
return -1
}
depth := 0
var visit func(*document.Node, int)
visit = func(current *document.Node, currentDepth int) {
if current == nil {
return
}
if currentDepth > depth {
depth = currentDepth
}
for _, entry := range current.Mapping {
visit(entry.Value, currentDepth+1)
}
for _, child := range current.Sequence {
visit(child, currentDepth+1)
}
}
visit(node, 0)
return depth
}

View File

@@ -0,0 +1,439 @@
package engine
import (
"os"
"path/filepath"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
t.Parallel()
limits := defaultImportLimits()
destination := Keys("collections", "entry")
empty := Mapping("empty", map[string]any{})
parsed, diagnostics := parseSource(empty, "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
perInput := countInputNodes(parsed.document) + destinationWrapperNodeCount(destination)
for _, test := range []struct {
name string
delta int
wantError bool
wantCharge int
}{
{name: "below aggregate bound", delta: -1, wantCharge: perInput},
{name: "at aggregate bound", delta: 0, wantCharge: perInput},
{name: "above aggregate bound", delta: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: empty, destination: destination,
}}}, 1, nil, accounting)
if test.wantError {
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
}
return
}
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
}
})
}
}
func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
t.Parallel()
limits := defaultImportLimits()
inputs := []loadInput{
{source: Mapping("one", map[string]any{}), destination: Keys("collection", "one")},
{source: Mapping("two", map[string]any{}), destination: Keys("collection", "two")},
{source: Mapping("three", map[string]any{}), destination: Keys("collection", "three")},
}
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
charge := len(inputs) * perInput
accounting := &importAccounting{nodes: limits.nodes - charge}
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
}
}
func TestNestedItemGlobsShareUnderlayNodeBudget(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: children/*.yml
destination: [children]
placement: sequence-item
id: parent
`)
fragmentPath := writeImportFile(t, directory, "children/a.yml", "id: child-a\n")
parseNodes := func(path string) int {
t.Helper()
parsed, diagnostics := parseSource(File(path), "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse %s: %#v", path, diagnostics)
}
return countInputNodes(parsed.document)
}
destination := Keys("groups")
childDestination := appendDestination(destination, Keys("children"))
otherDestination := Keys("other")
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
limits := defaultImportLimits()
for _, test := range []struct {
name string
delta int
wantError bool
}{
{name: "at aggregate bound"},
{name: "one node below aggregate bound", delta: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
}, {
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
}}}, 1, nil, accounting)
if test.wantError {
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
}
return
}
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
}
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
}
})
}
}
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
"fragment.yml": &fstest.MapFile{Data: []byte("{}\n")},
})
mainSource := filesystem.File("main.yml")
mainParsed, diagnostics := parseSource(mainSource, "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
fragmentParsed, diagnostics := parseSource(Bytes("fragment", []byte("{}\n")), "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
rawNodes := countInputNodes(mainParsed.document) + countInputNodes(fragmentParsed.document)
destination := Keys("collection", "empty")
placementNodes := 2 * destinationWrapperNodeCount(destination)
limits := defaultImportLimits()
for _, test := range []struct {
name string
delta int
wantError bool
}{
{name: "below aggregate bound", delta: -1},
{name: "at aggregate bound", delta: 0},
{name: "above aggregate bound", delta: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: mainSource, imports: true, destination: destination,
}}}, 1, nil, accounting)
if test.wantError {
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("over-budget imports = %#v, %#v", composition, diagnostics)
}
return
}
if len(diagnostics) != 0 || composition == nil || len(sources) != 2 {
t.Fatalf("bounded imports = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
}
if composition.inputNodes != rawNodes+placementNodes || accounting.nodes != limits.nodes+test.delta {
t.Fatalf("import node charge = %d, accounting %d; want %d and %d", composition.inputNodes, accounting.nodes, rawNodes+placementNodes, limits.nodes+test.delta)
}
if node, present := documentAtPath(composition.root, Keys("collection", "empty")); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("placed empty import body = %#v, present %v", node, present)
}
})
}
}
func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: empty.yml
destination: [groups, empty]
`)
fragmentPath := writeImportFile(t, directory, "empty.yml", "{}\n")
main := parseImportMain(t, mainPath)
fragment, diagnostics := parseSource(File(fragmentPath), "source-2", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("empty fragment diagnostics = %#v", diagnostics)
}
entryDestination := Keys("collections", "entry")
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
wantNodes := countInputNodes(main.document) + countInputNodes(fragment.document) +
destinationWrapperNodeCount(entryDestination) + destinationWrapperNodeCount(importDestination)
limits := defaultImportLimits()
for _, test := range []struct {
name string
delta int
wantError bool
}{
{name: "below aggregate bound", delta: -1, wantError: true},
{name: "at aggregate bound"},
{name: "above aggregate bound", delta: 1},
} {
t.Run(test.name, func(t *testing.T) {
currentLimits := limits
currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked(
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
)
if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
}
return
}
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
}
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
}
if sources[1].Destination.String() != importDestination.String() {
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
}
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty relative import body = %#v, present %v", node, present)
}
})
}
}
func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: fragments/*.yml
destination: [groups, empty]
`)
fragmentPaths := []string{
writeImportFile(t, directory, "fragments/a.yml", "{}\n"),
writeImportFile(t, directory, "fragments/b.yml", "{}\n"),
writeImportFile(t, directory, "fragments/c.yml", "{}\n"),
}
main := parseImportMain(t, mainPath)
fragment, diagnostics := parseSource(File(fragmentPaths[0]), "source-fragment", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("glob fragment diagnostics = %#v", diagnostics)
}
entryDestination := Keys("collections", "entry")
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
wantNodes := countInputNodes(main.document) + len(fragmentPaths)*countInputNodes(fragment.document) +
destinationWrapperNodeCount(entryDestination) + len(fragmentPaths)*destinationWrapperNodeCount(importDestination)
limits := defaultImportLimits()
for _, test := range []struct {
name string
delta int
wantError bool
}{
{name: "below aggregate bound", delta: -1, wantError: true},
{name: "at aggregate bound"},
{name: "above aggregate bound", delta: 1},
} {
t.Run(test.name, func(t *testing.T) {
currentLimits := limits
currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked(
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
)
if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
}
return
}
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 {
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics)
}
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
}
for index, source := range sources[1:] {
if source.Destination.String() != importDestination.String() {
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
}
}
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
}
})
}
}
func TestRelativeImportDestinationUsesAccumulatedDocumentDepth(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml, destination: [extra]}]}\n")
writeImportFile(t, directory, "fragment.yml", "value: nested\n")
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
for index := range destination.segments {
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
}
_, err := LoadLayered(
Input{Source: File(mainPath), Imports: true, Destination: destination}, testSecretBinding(),
LayeredOptions[map[string]any]{},
)
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportLimitExceeded {
t.Fatalf("accumulated depth result = %v", err)
}
sources := loadError.Sources()
if len(sources) != 2 || len(sources[0].Destination.segments) != maxDocumentDepth || len(sources[1].Destination.segments) != maxDocumentDepth+1 {
t.Fatalf("depth failure source destinations = %#v", sources)
}
}
func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
t.Parallel()
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
for index := range destination.segments {
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
}
for _, test := range []struct {
name string
body string
setup func(*testing.T, string)
}{
{
name: "optional missing path",
body: "configkit: {imports: [{path: missing.yml, optional: true, destination: [unused]}]}\n",
},
{
name: "optional unmatched glob",
body: "configkit: {imports: [{glob: empty/*.yml, optional: true, destination: [unused]}]}\n",
setup: func(t *testing.T, directory string) {
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
t.Fatal(err)
}
},
},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
if test.setup != nil {
test.setup(t, directory)
}
mainPath := writeImportFile(t, directory, "main.yml", test.body)
main := parseImportMain(t, mainPath)
limits := defaultImportLimits()
accounting := &importAccounting{}
composed, sources, diagnostics := loadImportedDocumentsTracked(
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
)
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
}
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
}
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty entry body = %#v, present %v", node, present)
}
})
}
}
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
t.Parallel()
accounting := &importAccounting{}
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
{source: NoSource(), destination: Keys("absent")},
{source: Mapping("root", map[string]any{})},
}}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
}
}
func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
t.Parallel()
limits := defaultImportLimits()
baseNodes := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("mounted"))
t.Run("selected overlay", func(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("bundle", fstest.MapFS{
"overlays/test.yml": &fstest.MapFile{Data: []byte("{}\n")},
})
request := loadRequest[map[string]any]{
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
t.Fatalf("overlay diagnostics = %#v", diagnostics)
}
})
t.Run("CLI override", func(t *testing.T) {
t.Parallel()
override := Override{Path: Keys("mounted", "value"), Value: "x", Flag: "--value"}
builder := valueBuilder{sourceID: "fixture", active: make(map[valueVisit]bool)}
if _, issue := builder.build(override.Value, override.Path, len(override.Path.segments)); issue != nil {
t.Fatal(issue)
}
overrideNodes := builder.nodes + destinationWrapperNodeCount(override.Path)
request := loadRequest[map[string]any]{
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
overrides: []Override{override},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
t.Fatalf("override diagnostics = %#v", diagnostics)
}
})
}

View File

@@ -0,0 +1,142 @@
package engine
import (
"path/filepath"
"testing"
"testing/fstest"
)
func TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations(t *testing.T) {
t.Parallel()
t.Run("strict conflict retains both source locations", func(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
{Source: Bytes("left", []byte("section:\n value: left\n")), Destination: Keys("application")},
{Source: Bytes("right", []byte("section:\n value: right\n")), Destination: Keys("application")},
}}}},
)
if loaded != nil {
t.Fatalf("conflicting load returned a result: %#v", loaded)
}
diagnostic := firstDestinationDiagnostic(t, err)
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/application/section/value" {
t.Fatalf("conflict diagnostic = %#v", diagnostic)
}
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
t.Fatalf("primary location = %#v", diagnostic.Primary)
}
if len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "source-2" || diagnostic.Related[0].Line != 2 {
t.Fatalf("related locations = %#v", diagnostic.Related)
}
})
t.Run("decode failure keeps effective path and content line", func(t *testing.T) {
t.Parallel()
type section struct {
Count int `yaml:"count"`
}
type config struct {
Application struct {
Section section `yaml:"section"`
} `yaml:"application"`
}
loaded, err := LoadLayered(
Input{Source: Bytes("main", []byte("section:\n count: invalid\n")), Destination: Keys("application")},
testSecretBinding(),
LayeredOptions[config]{},
)
if loaded != nil {
t.Fatalf("invalid load returned a result: %#v", loaded)
}
diagnostic := firstDestinationDiagnostic(t, err)
if diagnostic.Code != CodeDecodeType || diagnostic.Path.String() != "/application/section/count" {
t.Fatalf("decode diagnostic = %#v", diagnostic)
}
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
t.Fatalf("decode location = %#v", diagnostic.Primary)
}
})
t.Run("imported version failure uses effective path and import source", func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
writeImportFile(t, directory, "fragment.yml", "version: forbidden\n")
rule := &VersionRule{Path: Keys("application", "version"), Check: func(Version, *Validation) error { return nil }}
loaded, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: rule},
Underlays: []Underlay{{Inputs: []Input{{
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("application"),
}}}},
},
)
if loaded != nil {
t.Fatalf("invalid import returned a result: %#v", loaded)
}
diagnostic := firstDestinationDiagnostic(t, err)
if diagnostic.Code != CodeImportVersion || diagnostic.Path.String() != "/application/version" {
t.Fatalf("import version diagnostic = %#v", diagnostic)
}
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 {
t.Fatalf("import version location = %#v", diagnostic.Primary)
}
})
t.Run("invalid destination has no fictional source location", func(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered(
Input{Source: Bytes("main", []byte("value: present\n")), Destination: Keys("configkit")},
testSecretBinding(),
LayeredOptions[map[string]any]{},
)
if loaded != nil {
t.Fatalf("invalid destination returned a result: %#v", loaded)
}
diagnostic := firstDestinationDiagnostic(t, err)
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
t.Fatalf("invalid destination diagnostic = %#v", diagnostic)
}
})
t.Run("malformed source directive keeps its local path and location", func(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [true]}\nvalue: present\n")},
})
loaded, err := LoadLayered(
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("application")},
testSecretBinding(),
LayeredOptions[map[string]any]{},
)
if loaded != nil {
t.Fatalf("malformed directive returned a result: %#v", loaded)
}
diagnostic := firstDestinationDiagnostic(t, err)
if diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0" {
t.Fatalf("malformed directive diagnostic = %#v", diagnostic)
}
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 1 {
t.Fatalf("malformed directive location = %#v", diagnostic.Primary)
}
})
}
func firstDestinationDiagnostic(t *testing.T, err error) Diagnostic {
t.Helper()
loadError, ok := err.(*LoadError)
if !ok {
t.Fatalf("load error = %#v", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 {
t.Fatal("load error has no diagnostics")
}
return diagnostics[0]
}

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