165 Commits

Author SHA1 Message Date
d211537873 Remove completed roadmap documents 2026-11-01 19:05:56 +00:00
6db441893d Make consumer documentation easier to follow 2026-11-01 19:04:48 +00:00
e74c26a4b3 Catalog readability improvements for consumer documentation 2026-11-01 18:36:52 +00:00
08bb3a20c6 Share secret traversal between resolution and validation 2026-11-01 18:23:50 +00:00
b3bb57eda0 Record shared abstraction audit findings 2026-11-01 18:02:15 +00:00
94d1e333d4 Correct discovery documentation and test commands 2026-11-01 17:53:39 +00:00
ff10d03978 Record documentation audit findings 2026-11-01 17:48:52 +00:00
b4477a236a Update copyright statement to remove unnecessary language 2026-11-01 14:34:04 +00:00
d818b08556 Record successful remote vanity verification 2026-11-01 14:24:36 +00:00
587f407a32 Migrate ConfigKit to its vanity module path 2026-11-01 14:19:37 +00:00
335bd7bc31 Settle the Forge publication endpoint 2026-11-01 14:04:15 +00:00
331195202e Document the vanity module migration roadmap 2026-11-01 14:02:28 +00:00
21c9bc0ad0 Retire completed roadmaps before the next release 2026-10-27 19:15:40 +00:00
68400ffdfb Fix source lookup tests with symlinked temporary directories 2026-10-27 18:42:19 +00:00
5d5d51ab11 Record the public API cleanup review 2026-10-27 16:25:33 +00:00
69bd9ead53 Complete the public API cleanup roadmap 2026-10-27 15:44:00 +00:00
2ff588eb64 Update guidance for the cleaned public API 2026-10-27 15:40:22 +00:00
c96197c417 Add detached source lookup to results and errors 2026-10-27 15:26:19 +00:00
24ab623cb2 Rename the retained history limit diagnostic 2026-10-27 15:18:57 +00:00
1ce147c9f6 Use main for the main provenance layer 2026-10-27 15:15:39 +00:00
9833ca3aa3 Replace generic resolution recording with intent methods 2026-10-27 15:12:38 +00:00
eafbd32489 Clarify search and secret bootstrap names 2026-10-27 15:09:04 +00:00
9ac0053590 Clarify import default field names 2026-10-27 15:07:15 +00:00
791cf16136 Simplify empty import selection evidence 2026-10-27 15:05:22 +00:00
31ae30372e Clarify source version evidence names 2026-10-27 15:01:54 +00:00
7ee9e92ef5 Clarify resolution metadata and sensitivity names 2026-10-27 15:00:04 +00:00
556b10b2cd Plan the public API cleanup 2026-10-27 14:55:22 +00:00
07e4ae5d92 Expand accepted API decisions into an implementation roadmap 2026-10-27 14:43:08 +00:00
7548fa101a Accept descriptive import default field names 2026-10-27 14:35:42 +00:00
197567177e Include failed-load source lookup in A1 2026-10-27 14:31:25 +00:00
7330107389 Include obsolete recording diagnostic removal in D6 2026-10-27 14:29:35 +00:00
ad6d3748d9 Accept history terminology for the retained evidence limit 2026-10-27 14:27:56 +00:00
1ee508ba02 Accept main terminology for provenance 2026-10-27 14:26:54 +00:00
e9155fced6 Accept the discovery path constructor rename 2026-10-27 14:25:18 +00:00
14c83a6515 Record additional public API review opportunities 2026-10-27 14:21:01 +00:00
3ebb848ead Accept direct source lookup on results 2026-10-27 14:10:51 +00:00
4289f9ff7f Record accepted public API cleanup decisions 2026-10-27 14:06:45 +00:00
a050986916 Catalog the public API for version one 2026-10-27 13:34:26 +00:00
fb71421956 Prepare sensitivity dependencies once per recorded operation 2026-10-27 03:18:55 +00:00
b281adb751 Index explanation selections once per query 2026-10-27 03:15:01 +00:00
1448f9b19d Deduplicate parsed sensitivity paths correctly 2026-10-27 03:12:21 +00:00
731149fc6d Localize history selection to affected paths 2026-10-27 03:10:07 +00:00
9ff873f54c Index stable history trees during normalization 2026-10-27 03:08:34 +00:00
3f389662e9 Keep explanation origins scoped to the query 2026-10-27 03:05:15 +00:00
906ca34b6d Avoid duplicate intermediate source history 2026-10-27 03:04:31 +00:00
17216a40f3 Preserve final dynamic secret tags in inspection 2026-10-27 03:02:17 +00:00
896c1cde08 Record additional history scaling findings 2026-10-27 03:00:14 +00:00
91769e9f9b Record interface remediation review findings 2026-10-27 02:52:47 +00:00
98c8b7c842 Complete interface contract remediation 2026-10-26 18:23:58 +00:00
1954052631 Preserve composition conflict claimant order 2026-10-26 18:17:04 +00:00
3ffaef7ede Remove superseded history attribution paths 2026-10-26 18:11:04 +00:00
6a1447c327 Reconcile retained history accounting 2026-10-26 17:46:31 +00:00
12fd0a7191 Publish inspection through one frozen history model 2026-10-26 17:36:36 +00:00
8155465322 Move sensitivity classification to shared history 2026-10-26 17:25:11 +00:00
408dd13c24 Integrate history prefixes into load handoffs 2026-10-26 17:15:30 +00:00
738c8a9cc7 Replay explanations through shared history selections 2026-10-26 17:01:32 +00:00
4b4e15fc58 Cut metadata history over to shared model 2026-10-26 16:34:13 +00:00
76521f7cd8 Complete historical inventory and query services 2026-10-26 16:09:32 +00:00
cacc558288 Normalize original resolution history evidence 2026-10-26 15:49:15 +00:00
231ae66857 Normalize typed CLI history evidence 2026-10-26 15:36:42 +00:00
da74d4190d Normalize inheritance history evidence 2026-10-26 15:25:02 +00:00
11f7154874 Normalize projected collection history evidence 2026-10-26 15:09:12 +00:00
3828b117e8 Normalize baseline and source history evidence 2026-10-26 15:01:54 +00:00
2419e63717 Add shape-aware history selection core 2026-10-26 14:47:18 +00:00
a893c7716c Preserve explicitness across resolution shape changes 2026-10-26 14:36:15 +00:00
28ef9b6200 Use entry metadata for resolution presence 2026-10-26 14:30:25 +00:00
0dd16aa8a9 Plan the interface remediation sprint 2026-10-26 14:24:07 +00:00
f6d54a094a Clarify shared history migration and sprint acceptance 2026-10-26 14:00:18 +00:00
f9ec66e867 Adopt shared history ownership in the interface roadmap 2026-10-26 12:36:17 +00:00
be5cdeb502 Plan interface contract remediation 2026-10-26 02:40:21 +00:00
1f64673d0d Record API contract audit findings 2026-10-26 01:36:20 +00:00
01b08e76a3 Retire the completed consolidated audit 2026-10-26 01:25:35 +00:00
453da684d7 Write release notes for v0.10.2 2026-10-26 01:19:38 +00:00
fb6c254d38 Mark import simplification roadmap complete 2026-10-26 00:21:57 +00:00
a71413ce67 Document import and facade boundaries 2026-10-26 00:21:40 +00:00
d66338a1c5 Construct placement directly across the facade 2026-10-26 00:18:12 +00:00
643f43ce6a Snapshot import defaults at load preflight 2026-10-26 00:13:34 +00:00
c25cf39d19 Consolidate engine import defaults 2026-10-26 00:10:07 +00:00
7d18c93339 Extract patterned glob entry preparation 2026-10-26 00:08:23 +00:00
b1d89810ca Plan discovery and facade boundary refactoring 2026-10-26 00:03:02 +00:00
bb5540b62e Plan import discovery and facade simplification 2026-10-25 23:55:45 +00:00
80cf23e710 Mark the implementation roadmap complete 2026-10-25 23:26:29 +00:00
ac48557535 Document and verify the remediation sprint 2026-10-25 23:26:10 +00:00
b4af900c10 Index secret failure locations lazily 2026-10-25 23:21:29 +00:00
f132cd6468 Index inherited compound identity restoration 2026-10-25 23:17:59 +00:00
75e209eb3b Index tier history sources lazily 2026-10-25 23:13:00 +00:00
1dd94a3cc6 Share sequence history construction 2026-10-25 23:09:41 +00:00
d172a5e46a Apply sparse collection policy across overlays 2026-10-25 23:05:23 +00:00
3a1b6ff355 Borrow lower collection policies during strict composition 2026-10-25 22:59:03 +00:00
68383f34de Index collection policy retirement 2026-10-25 22:49:02 +00:00
549fca239d Separate collection overlay responsibilities 2026-10-25 22:42:21 +00:00
883244936c Share layered tier application 2026-10-25 22:37:46 +00:00
a8ae2e9d5e Clarify load context and inheritance handoffs 2026-10-25 22:34:52 +00:00
f18582106b Isolate standalone input completion 2026-10-25 22:32:52 +00:00
d8bf6abc2b Isolate source directive processing 2026-10-25 22:30:46 +00:00
4bf940c579 Use deferred acquisition consistently 2026-10-25 22:28:35 +00:00
4fd3bee986 Strengthen composition and import selection tests 2026-10-25 22:21:51 +00:00
6f6130fd12 Use production directive extraction in import tests 2026-10-25 22:19:38 +00:00
3122e2f32d Remove unread private transport members 2026-10-25 22:18:12 +00:00
e4a5ecb4be Remove disconnected facade clone scaffolding 2026-10-25 22:13:00 +00:00
826062aa17 Test public historical path enumeration 2026-10-25 22:09:55 +00:00
08f2a8f3e9 Cache approved dynamic descriptors per operation 2026-10-25 22:07:37 +00:00
12d350a292 Enforce the retained history node limit 2026-10-25 22:04:54 +00:00
4fcff5f453 Prepare inspection history before publication 2026-10-25 21:53:24 +00:00
247a12bc50 Remove unused combination evidence and helpers 2026-10-25 21:49:52 +00:00
119b42c810 Remove unused history document copies 2026-10-25 21:47:53 +00:00
0f9ecf12ed Share link dependency evaluation with host access 2026-10-25 21:45:33 +00:00
7bf2f0c687 Memoize completed native link dependencies 2026-10-25 21:43:26 +00:00
8aba43da38 Preserve directory traversal failure phases 2026-10-25 21:33:36 +00:00
877c497325 Close owned import roots during panic unwinding 2026-10-25 21:27:47 +00:00
08a8c25542 Close callback contexts when callbacks panic 2026-10-25 21:25:57 +00:00
ea27f17f5c Check projection budgets before container allocation 2026-10-25 21:25:00 +00:00
ae3408a096 Distinguish finite slice views from cycles 2026-10-25 21:22:01 +00:00
deb9aa5c69 Roll back failed dynamic descriptor builds 2026-10-25 21:20:41 +00:00
2736ddfbf2 Terminate pointer-only descriptor traversal 2026-10-25 21:18:35 +00:00
7889ebddfb Plan the audit remediation sprint 2026-10-25 20:52:11 +00:00
4a5316b7fc Describe the audit remediation target and dependencies 2026-10-25 20:25:18 +00:00
e1e94eeec7 Settle audit refactoring scope and defer follow-up work 2026-10-25 20:17:47 +00:00
176e9a5d90 Select localized audit efficiency improvements 2026-10-25 20:13:57 +00:00
23c1976ef1 Settle the initial audit remediation scope 2026-10-25 20:12:12 +00:00
aab79d9508 Consolidate repository audit findings for review 2026-10-25 20:07:46 +00:00
ecd5d5d460 Record dead code and obsolete functionality audit 2026-10-25 17:22:25 +00:00
b1c278b38b Record test coverage and effectiveness audit findings 2026-10-25 17:07:39 +00:00
eb5a82f1f3 Record security audit findings 2026-10-25 16:53:57 +00:00
b30c9f167d Record error handling audit findings 2026-10-25 16:44:08 +00:00
5925066d35 Record resource management audit findings 2026-10-25 16:37:05 +00:00
0cca633a67 Record concurrency and lifetime audit results 2026-10-25 16:25:10 +00:00
a80273433e Record maintainability and complexity audit findings 2026-10-25 16:18:07 +00:00
320d0c5f43 Record algorithmic efficiency audit findings 2026-10-25 16:05:49 +00:00
5098b56655 Record functional correctness audit findings 2026-10-25 15:52:43 +00:00
47944d22d1 Update ReleaseKit to v0.4.2 2026-10-25 15:37:32 +00:00
50199eb0af Retire completed consolidation roadmap documents 2026-10-25 15:09:34 +00:00
34cbc0d63b Document ConfigKit v0.10.1 2026-10-25 15:07:06 +00:00
5e12b2d360 Retire the completed clone traversal review 2026-10-25 14:58:56 +00:00
8dfaf44d1d Index import dependencies for local scope scheduling 2026-10-25 14:41:07 +00:00
64be8b4df5 Keep import selection evidence with its declaring source 2026-10-25 14:36:39 +00:00
e585decdb2 Reuse validated filesystem facts through acquisition 2026-10-25 14:33:17 +00:00
6440a500f7 Record import bookkeeping scalability findings 2026-10-25 14:29:40 +00:00
d898cb193d Record the remaining context consolidation gap 2026-10-25 14:00:29 +00:00
557e006a4a Complete consolidation roadmap validation 2026-10-25 13:51:36 +00:00
3fd83b6b2b Document consumer and internal loading flows 2026-10-25 13:49:44 +00:00
0fbce5d141 Separate schema field discovery and assembly 2026-10-25 13:46:51 +00:00
ac4ce242a2 Separate reflection projection handlers 2026-10-25 13:45:27 +00:00
fa6e876ab5 Extract inheritance accounting and finalization 2026-10-25 13:43:26 +00:00
c5275df1fc Return inheritance combination evidence together 2026-10-25 13:41:41 +00:00
f404168900 Extract inheritance input preparation 2026-10-25 13:32:01 +00:00
729c6fb5d5 Extract imported source admission from traversal 2026-10-25 13:28:43 +00:00
d314a269d5 Separate directory entry admission from traversal 2026-10-25 13:25:37 +00:00
2c3eb3308b Extract import declaration parsing helpers 2026-10-25 13:23:09 +00:00
6bc625b68f Split import acquisition and projection responsibilities 2026-10-25 13:21:29 +00:00
b915dd776a Consolidate selected overlay acquisition 2026-10-25 13:18:54 +00:00
9a64a65b6c Carry resolved filesystem facts through loads 2026-10-25 13:13:47 +00:00
6158e9db3b Separate filesystem context from access 2026-10-25 13:09:44 +00:00
0d149e51f0 Wire load lifecycle through input acquisition 2026-10-25 13:07:16 +00:00
bd51b9c3f2 Consolidate import request ownership 2026-10-25 13:02:46 +00:00
d05426d8a2 Introduce non-generic input acquisition requests 2026-10-25 12:55:04 +00:00
3f0efcb4ff Normalize ordinary and grouped load preflight 2026-10-25 12:52:50 +00:00
19f5c2a2d5 Remove unused reverse source conversions 2026-10-25 12:50:11 +00:00
455a4a355d Map ConfigKit consolidation callers and coverage 2026-10-25 12:37:33 +00:00
8ab1568352 Plan the ConfigKit consolidation sprint 2026-10-25 12:14:01 +00:00
8a99e61070 Defer clone traversal review to future work 2026-10-25 11:54:46 +00:00
de7513d488 Settle retaining the public loading APIs 2026-10-24 23:07:09 +00:00
26771dc03e Consolidate refactoring roadmaps and record open questions 2026-10-24 22:04:26 +00:00
fbaaaf18cd Draft the ConfigKit consolidation roadmap toward 1.0 2026-10-24 21:51:29 +00:00
c9574ef3c1 Record complexity audit findings 2026-10-24 19:54:13 +00:00
264 changed files with 12938 additions and 5794 deletions

View File

@@ -18,8 +18,3 @@ 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.

137
README.md
View File

@@ -1,56 +1,18 @@
# ConfigKit # ConfigKit
An opinionated Go library for applications that use YAML configuration files. ConfigKit loads YAML into your application's Go configuration type, combines the
inputs you choose, and lets you inspect where the final values came from. You
supply the types, defaults, and application rules; ConfigKit handles loading,
combination, strict decoding, and inspection.
Linux is release-validated; macOS support is best-effort. See the Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details. [platform support policy](docs/policy/release.md#platform-support).
ConfigKit's simple `Load` accepts one strict YAML source (or explicit ## Load a configuration file
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
source groups with an independently selected main input. Sources can be host
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
Enabled explicit imports compose strictly within their group; precedence
between groups combines mappings recursively. Both entry points return a complete,
validated configuration with detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply after inheritance and typed decoding, before application
resolution. Value explanation is available through
detached structured records and deterministic redacted output. Built-in
`extends` relationships let a mapping inherit from a named sibling during the
same load, retaining explicit presence and source/derivation history. The root
`Diff` API compares frozen effective values from two successful same-type loads;
the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
Sources can also be explicitly placed as sequence items. Configuration
declarations and application registrations can give selected sequences scalar
identities and choose how they combine across precedence and inheritance.
Strict peers combine distinct records, while higher-priority collections can
replace, append, or upsert whole records. See the
[sequence contract](docs/config.md#strict-document-composition),
[maintained catalog examples](docs/consumers/README.md#identified-prompt-sequences),
and [ADR 0017](docs/adr/0017-collection-sequence-policy.md).
Imports are also explicit: a source may name files, search declared directories,
or select files with path globs. Applications can set load-wide import defaults
while keeping declaration-specific exceptions. Files are acquired in authored
declaration order; dependency-ready composition uses a shared stable scheduler.
ConfigKit does not search conventional application paths implicitly. See the
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
[ADR 0016](docs/adr/0016-independent-import-controls.md).
Main-file discovery is explicit too: applications choose and order `Search`
descriptions and pass `SearchConfig` to the existing load APIs. See the
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
```go ```go
import "go.maximumdirect.net/configkit"
type Config struct { type Config struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"` Workers int `yaml:"workers"`
@@ -65,48 +27,57 @@ func ReadConfig(path string) (Config, error) {
} }
``` ```
Existing consumers should follow the
[module-path migration guidance](docs/consumers/README.md#vanity-module-path-migration).
## Add the features you need
Start with one file. Optional features use the same loading and inspection model:
- [Choose inputs](docs/consumers/README.md#choosing-a-loading-entry-point): read
files, an application-owned `fs.FS`, bytes, a reader, or a Go mapping. Use
explicit search descriptions when the application should look in several places.
- [Supply lower-priority settings](docs/consumers/README.md#native-filesystem-and-layered-input):
use `LoadLayered` for bundled defaults that application configuration can override.
- [Combine configuration](docs/config.md#explicit-imports): add declared imports,
select one overlay, or pass values explicitly set by your CLI parser.
- [Reuse settings](docs/config.md#sibling-inheritance): let one named entry inherit
from a sibling. [Identified lists](docs/config.md#strict-document-composition)
also support record inheritance and replace, append, or upsert operations.
- [Apply application rules](docs/config.md#recorded-application-resolution): derive
or normalize values in a resolver, then check the final configuration in a validator.
- [Handle credentials](docs/config.md#typed-secret-references): use environment-name
references and choose which configuration fields to hide in inspection output.
- [Inspect a load](docs/config.md#layered-metadata): examine sources, presence,
history, safe YAML output, or [changes between two loads](docs/config.md#effective-configuration-diffs).
The [consumer guide](docs/consumers/README.md) walks through these tasks and links
to maintained examples. The [configuration reference](docs/config.md) defines the
exact YAML, combination, and lifecycle rules.
## Design philosophy ## Design philosophy
ConfigKit favors one coherent, predictable configuration model over flexibility ConfigKit favors consistent, predictable rules across applications. A load returns
for reproducing each consumer's conventions. Its goal is to let applications the final configuration and metadata that distinguish supplied values such as
load once, inspect where final values came from, and distinguish explicit values `false` and `0` from omissions. Applications keep their domain rules and runtime
from omissions with minimal configuration bookkeeping. Applications retain their initialization.
domain rules and execution responsibilities.
Loading is synchronous and complete on return. Applications normally load once Within one layer, independent contributions combine strictly: conflicting
near startup and then use the final configuration and metadata; ConfigKit stays values are errors. Across layers, higher-priority settings can replace
inactive until explicitly called again. Applications may initiate additional lower-priority settings, while mappings combine by key. Identified lists have
loads as needed. See the [loading lifecycle](docs/config.md#completion-and-resource-lifetime) their declared combination rules. See the [worked combination
for completion and ownership details. examples](docs/config.md#strict-document-composition) for the details and
exceptions.
Within a configuration tier, independent contributions compose strictly: shared Loading finishes before the call returns. ConfigKit then stays inactive until you
mappings combine, while overlapping non-mapping definitions are errors. Between call it again; inspection uses the saved result. See
precedence tiers, mappings combine recursively, retaining omitted lower-priority [completion and resource lifetime](docs/config.md#completion-and-resource-lifetime).
children; other values replace lower-priority values. The main document and its
imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay applies before inheritance, while
explicit CLI overrides apply after decoding and before application resolution.
The [configuration contract](docs/config.md#strict-document-composition) owns
the detailed combination rules and input-specific constraints. Declared or
registered collections combine distinct identified records within strict tiers
and use their selected operation at precedence boundaries; other sequences keep
their strict-conflict and whole-value-replacement behavior.
The [architecture goals](docs/policy/architecture.md#success-criteria) explain The [architecture goals](docs/policy/architecture.md#success-criteria) and
these priorities and how genuine new needs can justify extending the shared [ADRs](docs/adr) explain design decisions, including shared import and inheritance
model. The [future feature list](docs/roadmap/future.md) preserves candidates for ordering. [Future work](docs/roadmap/future.md) lists possible extensions.
consideration in future releases. Contributors can start with the [development guide](docs/development.md);
release maintainers use the [release procedure](docs/release.md).
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). ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

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

View File

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

22
diff.go
View File

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

View File

@@ -1,6 +1,6 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "go.maximumdirect.net/configkit/internal/engine"
// Search is an immutable ordered description of possible host-file inputs. // Search is an immutable ordered description of possible host-file inputs.
// It is consumed only when passed to SearchConfig and used by a load. // It is consumed only when passed to SearchConfig and used by a load.
@@ -52,8 +52,8 @@ func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Searc
return Search{value: engine.SearchDirectory(directory, options...)} return Search{value: engine.SearchDirectory(directory, options...)}
} }
// Paths describes literal host-file candidates in the supplied order. // SearchPaths describes literal host-file candidates in the supplied order.
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} } func SearchPaths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
// Searches concatenates descriptions in order and snapshots their entries. // Searches concatenates descriptions in order and snapshots their entries.
func Searches(searches ...Search) Search { func Searches(searches ...Search) Search {

22
doc.go
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +1,111 @@
# Consumer Adoption Guide # Consumer Adoption Guide
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and ConfigKit loads configuration into your application's Go types. You provide
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from those types, defaults, and application rules. ConfigKit reads and combines the
applications. Consumers still own their configuration structs, defaults, semantic selected inputs, checks their types, and returns values with source information.
validation, schema-version policy, CLI parsing, runtime matching and conversion, Your application still owns CLI parsing, resource reads, remote acquisition, and
resource checks, remote acquisition, and service initialization. runtime initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates Import `"go.maximumdirect.net/configkit"`; the package name is `configkit`.
sanitized shapes using only ConfigKit and the Go standard library: Start with one file, then choose the tasks your application needs:
| Shape | Demonstrated fit | Application code deliberately retained | | Task | Start here |
| --- | --- | --- | | --- | --- |
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field | | Load one file | [Happy path](#happy-path) and the [README example](../../README.md#load-a-configuration-file) |
| 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` | | Supply fixed defaults and check application rules | [Defaults and application rules](#defaults-and-application-rules) |
| 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 | | Add bundled defaults beneath application settings | [Native filesystem and layered input](#native-filesystem-and-layered-input) |
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file | | Select files or an overlay | [Discovery](#explicit-main-file-discovery), [import defaults](#import-defaults-and-selection-inspection), and the [overlay reference](../config.md#selected-overlays) |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem | | Reuse settings or combine record lists | [Inheritance](#named-sibling-inheritance) and [identified sequences](#identified-prompt-sequences) |
| 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 | | Handle credentials | [Typed-secret reference](../config.md#typed-secret-references) and the [startup example](../../examples/consumers/consumers_test.go) |
| 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 | | Inspect sources, earlier values, or changes | [Inspect a completed load](#inspect-a-completed-load) and [diffs](#effective-configuration-diffs) |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset | | Upgrade an existing consumer | [Migration guidance](#intentional-migration-changes) |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the ## Choosing a loading entry point
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
The Notarius-shaped fixtures deliberately load both `version: 4` and - Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
`version: "4"` into the same string-backed application field. Its version checker [`SearchConfig`](../../discovery.go) is also a source; pass it to `Load` or use
still owns the supported identifier; fractional numeric input and unsupported it as the main input to [`LoadLayered`](../../layered.go).
integer identifiers remain negative examples. - Use `LoadLayered` when several inputs should supply lower-priority defaults
beneath the main source. Each underlay group combines its inputs strictly.
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
import choice. The overlay filesystem context is selected independently in
[`LayeredOptions`](../../layered.go).
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
[`LayeredOptions`](../../layered.go): the application definition (including
version policy), import defaults, and explicit overrides. These do not turn
on imports or discovery by themselves. See the
[configuration contract](../config.md#layered-loading) and
[discovery contract](../config.md#configuration-discovery) for exact rules.
The root [README example](../../README.md) shows the small `Load` path.
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
file and layered cases. For import-focused adoption, see the
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
discovery composed with sequences, see the
[sequence catalog](../../examples/consumers/sequence_catalog.go).
## Happy path ## Happy path
An application defines ordinary types and passes one explicit source. Optional Define the Go configuration type, choose one source, and call `Load`. Handle a
features appear only when the application needs them: load error before starting the application with `result.Config`. The
[README example](../../README.md#load-a-configuration-file) shows this complete
minimal loading function. An explicitly missing file is an error; use `NoSource`
when you deliberately want defaults without a main document.
### Defaults and application rules
Using the README's `Config` type, an application can add reusable rules:
```go ```go
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{ definition := configkit.Definition[Config]{
Definition: rules, Defaults: func() Config { return Config{Enabled: true, Workers: 2} },
Imports: useDeclaredImports, Validate: func(value *Config, validation *configkit.Validation) {
Overlay: selectedConfigOverlay, if value.Workers < 0 {
Overrides: explicitlySetFlags, validation.Error(configkit.Keys("workers"), "must be nonnegative")
}) }
if err != nil { },
return err
} }
return start(result.Config)
``` ```
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into Pass this definition in `Options[Config]{Definition: definition}` when calling
both without redefining their syntax. ConfigKit overlays are configuration overlays; `Load`. Supplied `enabled: false` or `workers: 0` replaces those defaults.
they do not select or replace an application's LLM, model, pipeline, report, or Use `Resolve` when a value must be derived or normalized before validation, and
execution profile concepts. record that change with the provided helpers. The
[callback reference](../config.md#recorded-application-resolution) compares the
helpers; [executable examples](../../example_test.go) show full definitions.
### Inspect a completed load
Keep the result when you need to explain configuration. Use
`result.Metadata().Lookup(configkit.Keys("workers"))` to inspect whether input
supplied the value and which source or application step supplied it. Follow an
origin's source ID with `result.Source(id)` when you need its source record.
Use `Explain` for earlier values, `WriteExplanation` for a readable explanation,
and `WriteEffective` for safe YAML describing the final saved state. These
operations describe the completed load even if your application later changes
`result.Config`. The [inspection reference](../config.md#layered-metadata)
explains returned values, copies, and hidden information.
Add imports, a selected overlay, or explicitly set CLI values through options
only when needed. An overlay changes configuration; choosing an LLM, report, or
execution profile remains application logic.
## Explicit main-file discovery ## Explicit main-file discovery
Applications choose the search conventions and their precedence, then pass the Applications choose the search conventions and their precedence, then pass the
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point. resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry
For example, the maintained sequence catalog puts a caller-selected file before point. For example, the maintained sequence catalog puts a caller-selected
a custom directory fallback while keeping its underlay and overlay choices file before a custom directory fallback while keeping its underlay and overlay
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go) choices independent. See
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go) and its
the [working-directory executable example](../../examples/consumers/discovery_test.go). [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus the
Together, the root-owned [discovery API](../../discovery.go) and [configuration [working-directory executable
discovery contract](../config.md#configuration-discovery) define ordered example](../../examples/consumers/discovery_test.go). Together, the [discovery
concatenation, directory-first Cartesian expansion, optional exhaustion, and API](../../discovery.go) and [configuration discovery
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md) contract](../config.md#configuration-discovery) explain the order of attempted
files, what happens when no file is found, and why a selected-file failure
stops the search. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records why discovery is explicitly selected. records why discovery is explicitly selected.
## Import defaults and selection inspection ## Import defaults and selection inspection
@@ -75,15 +113,20 @@ records why discovery is explicitly selected.
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one `Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
load-wide policy to enabled main, underlay, and transitive imports. Each field load-wide policy to enabled main, underlay, and transitive imports. Each field
is resolved independently: a declaration's explicit value wins, then the is resolved independently: a declaration's explicit value wins, then the
application default, then the library default. `Required`, `Follow`, `Hidden`, application default, then the library default. `Required`, `FollowSymlinks`,
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to `IncludeHidden`, and `Recursive` use nil pointers for unspecified values.
`Pattern` applies to
directory searches: nil leaves filenames unrestricted, while a non-nil empty directory searches: nil leaves filenames unrestricted, while a non-nil empty
slice selects no files. A declaration's own `pattern` replaces the application slice selects no files. A declaration's own `pattern` replaces the application
default. The defaults do not enable imports or change selected-overlay behavior. default. The defaults do not enable imports or change selected-overlay behavior.
The Go fields `FollowSymlinks` and `IncludeHidden` correspond to the unchanged
YAML keys `follow` and `hidden`.
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each `Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
declaring source's `ImportSelections`. These records identify the source-local declaring source's `EmptyImportSelections`. `Result.Source(id)` and
declaration path, its authored location and selector, and the `none` outcome; `LoadError.Source(id)` retrieve one retained record directly when its load-local
ID is known. These records identify the source-local declaration path, its
authored location and selector;
they do not include candidate names or a guessed reason for absence. Returned they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports) records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics. owns selector applicability and exact absence semantics.
@@ -93,7 +136,7 @@ owns selector applicability and exact absence semantics.
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates [`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in `SourceInfo.VersionEvidence` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The [`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines [configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption, participation, source-local lookup and diagnostics, non-main header consumption,
@@ -111,13 +154,14 @@ priority values remain present. Its resolver records a derived endpoint URL afte
overlay application, and its nested typed secret reference is classified before overlay application, and its nested typed secret reference is classified before
the resulting records are presented. the resulting records are presented.
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go) The maintained [behavior tests and executable
use temporary files and disposable secret values. Presenters should use examples](../../examples/consumers/diff_test.go) use temporary files and
`ValueSnapshot.Present` for absence, record membership for change, and disposable secret values. Presenters should use `ValueSnapshot.Present` for
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker absence, record membership for change, and `HiddenChanged` for a withheld
or add separately rendered full contexts that lack combined sensitivity. The change indicator. Do not parse the redaction marker or add separately rendered
[configuration contract](../config.md#effective-configuration-diffs) owns shared full contexts that lack combined sensitivity. The [configuration
equality, granularity, and disclosure semantics. contract](../config.md#effective-configuration-diffs) owns shared equality,
granularity, and disclosure semantics.
## Named sibling inheritance ## Named sibling inheritance
@@ -240,14 +284,13 @@ conversion API.
## Source-aware application path policy ## Source-aware application path policy
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained [`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
path-bearing example. Its application resolver uses `Resolution.InputInfo` and path-bearing example. Its application resolver uses `Resolution.EntryInfo` and
`Resolution.Source` before normalizing: relative CLI input uses an explicitly `Resolution.Source` before normalizing: relative CLI input uses an explicitly
supplied invocation directory, retained defaults use the canonical main source, supplied invocation directory, retained defaults use the canonical main source,
and a file-authored scalar uses its one declaring source. Absolute paths remain and a file-authored scalar uses its one declaring source. Absolute paths remain
unchanged. Its executable test covers the matching effective YAML and explanation unchanged. Its executable test checks the resulting YAML and explanation
history, source-file ownership, and a symlink opened through an alias; canonical history. When the configuration filename is a symlink, this example uses the
target-file directories, rather than the alias directory or process working actual target file's directory as the base for file-authored paths.
directory, are the selected bases.
The selected example policy leaves a relative value unchanged and emits a warning The selected example policy leaves a relative value unchanged and emits a warning
when a CLI base is absent or file provenance is missing or composite. That is an when a CLI base is absent or file provenance is missing or composite. That is an
@@ -257,7 +300,15 @@ existence checks nor confinement checks.
## Intentional migration changes ## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention: When upgrading, start with the changes relevant to your dependency and inputs:
1. Update the module requirement and Go imports together as described below.
2. Replace old public names using the old/new table.
3. Update any layer or diagnostic-code strings stored or compared by your code.
4. Review YAML and startup changes that apply to your application, then rebuild
and run its configuration tests.
The maintained examples illustrate these configuration changes:
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional. - Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
@@ -277,10 +328,70 @@ The examples preserve domain meaning while adopting one configuration convention
parsers translate only explicitly set configuration flags into `Override` records. parsers translate only explicitly set configuration flags into `Override` records.
Separate invocation state remains outside configuration. Separate invocation state remains outside configuration.
- Credential fields migrate to environment-name `Secret` references. Applications - Credential fields migrate to environment-name `Secret` references. Applications
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap; explicitly call `LoadSecretsIntoEnvironment` before `Load` when they choose
file bootstrap;
blocked empty variables remain blocked, and ordinary `Load` never changes the blocked empty variables remain blocked, and ordinary `Load` never changes the
process environment. process environment.
### Vanity module path migration
ConfigKit's module identifier changed from
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit` before
v1.0.0. Update the dependency requirement in your application's `go.mod` and its
Go imports together, selecting a revision or release that declares the new module
path. The package name remains `configkit`; the configuration model and public
symbol names are unchanged by this identity migration.
Go treats the old and new import paths as different packages, with different
named types. For example, if another library returns a ConfigKit `Path` or
`Secret` using the old import, your application cannot use it as the new
package's type just by changing a module requirement. Coordinate migration
with dependencies that pass ConfigKit values to or from your application. The
maintained examples use the new path, but other repositories must manage their
own migrations.
Historical tags keep their original module declarations and contents. Older
releases such as v0.10.2 still use the old path and cannot be installed under the
vanity identifier simply because discovery can find their repository. A matching
migrated revision is needed; no old-path compatibility module is supplied here.
### Pre-v1 public API cleanup
The pre-v1 API cleanup replaces these names outright; it does not retain
compatibility aliases:
| Previous API | Current API |
| --- | --- |
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
| `Definition.Secret` | `Definition.Sensitive` |
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
| `SourceVersionCheck` | `SourceVersionEvidence` |
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
| `ImportSelection` | `EmptyImportSelection` |
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
| `Paths(paths...)` | `SearchPaths(paths...)` |
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
| `LayerBase` | `LayerMain` |
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
| `CodeResolveInvalidKind` / `CodeOptionsUnavailable` | Removed; no replacement |
| `ImportSelection.Outcome`, `ImportSelectionOutcome`, `ImportSelectionNone` | Removed; empty-selection records no longer carry an outcome |
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
| Enumerating `Result.Sources()` / `LoadError.Sources()` to find one ID | `Result.Source(id)` / `LoadError.Source(id)` |
| Provenance string `"base"` | `"main"` |
| History diagnostic string `"explain.limit_exceeded"` | `"history.limit_exceeded"` |
The YAML import keys `follow` and `hidden`, the `configkit:"secret"` tag, and
typed `Secret` references are unchanged. If your application compares or
stores layer names or diagnostic codes as strings, update those strings
alongside Go references. This repository guidance does not claim that
downstream applications have migrated and does not select a release version
for this breaking pre-v1 change. `ChangeKind` and its inspection constants
remain; use `Default` and `Normalize` when the helper should both assign and
record a value.
### Selected-overlay migration ### Selected-overlay migration
To adopt the current selected-overlay API, rename `Options.Profile`, To adopt the current selected-overlay API, rename `Options.Profile`,
@@ -291,10 +402,40 @@ The [configuration contract](../config.md#selected-overlays) is the canonical
behavioral reference. Application-owned profile concepts, such as `model_profile`, behavioral reference. Application-owned profile concepts, such as `model_profile`,
are unchanged, and any CLI flag rename remains the application's decision. are unchanged, and any CLI flag rename remains the application's decision.
These examples are migration evidence, not completed changes to Narratio, Notarius, ### Background on the examples
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
wiring, runtime catalogs, and operational documentation remains separate downstream These examples demonstrate migration shapes. They do not claim that Narratio,
work. Notarius, WeatherReporter, or Seriatim has been migrated. Updating those
repositories, their deployed YAML, CLI wiring, runtime catalogs, and
operational documentation remains separate downstream work.
## Example background
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Artifact defaults and normalization (Narratio-like) | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Versions and dynamic options (Notarius-like) | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| Module validation and credential startup (WeatherReporter-like) | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Independent ordered rule files (Seriatim-like) | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
The Notarius-shaped fixtures deliberately load both `version: 4` and
`version: "4"` into the same string-backed application field. Its version checker
still owns the supported identifier; fractional numeric input and unsupported
integer identifiers remain negative examples.
## Maintained API examples ## Maintained API examples
@@ -306,7 +447,7 @@ focused behavioral owner:
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) | | `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture | | Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | | `LoadSecretsIntoEnvironment`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests | | Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` | | Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` | | Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |

View File

@@ -113,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
``` ```
Remaining optimization candidates belong to Remaining optimization candidates belong to
[future performance experiments](roadmap/future.md#3-performance-experiments). [future performance experiments](roadmap/future.md#5-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under resolved review findings are preserved in Git history at `1c2610c` under
@@ -189,7 +189,42 @@ remediation record are preserved in Git history at `0369111`, for example
[configuration discovery](config.md#configuration-discovery); the public [configuration discovery](config.md#configuration-discovery); the public
[discovery declarations](../discovery.go) own the Go API. [discovery declarations](../discovery.go) own the Go API.
The completed consolidation proposal, implementation plan, and resolved audit
findings are preserved in Git history at `34cbc0d` under `docs/roadmap/`. For
example:
```sh
git show 34cbc0d:docs/roadmap/implementation.md
git show 34cbc0d:docs/roadmap/remediation.md
```
Potential enhancements are tracked in [future features](roadmap/future.md). Potential enhancements are tracked in [future features](roadmap/future.md).
The completed audit-remediation, internal-boundary simplification, shared-history,
and public API cleanup roadmaps, reviews, and resolved findings are preserved at
`68400ff` under `docs/roadmap/`. For example:
```sh
git show 68400ff:docs/roadmap/api.md
git show 68400ff:docs/roadmap/remediation.md
```
Their earlier completed implementation plans are preserved at
`bb5540b:docs/roadmap/implementation.md` (audit remediation),
`f6d54a0:docs/roadmap/implementation.md` (internal-boundary simplification), and
`07e4ae5:docs/roadmap/implementation.md` (shared history). The plan at
`68400ff:docs/roadmap/implementation.md` records the public API cleanup.
Current behavior and migration information belong to the
[configuration contract](config.md), public declaration comments, and
[consumer guide](consumers/README.md#pre-v1-public-api-cleanup).
The consolidated audit, including
findings, evidence, and settled scope decisions, is preserved at
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
```sh
git show 453da68:docs/roadmap/audit.md
```
Install the Go version required by `go.mod` or a compatible newer toolchain. Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline Acquire declared dependencies explicitly before entering an offline
@@ -237,22 +272,22 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command | | Task | Behavioral test owners | Focused command |
| --- | --- | --- | | --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` | | Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery\|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset\|SequenceCatalog'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` | | Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import\|ScheduleDependencies\|Inheritance'`, `go test . -run 'ImportDefaults\|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` | | Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` | | Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` | | Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` | | Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` | | Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` | | Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination\|Import\|Glob\|Mounted'`, `go test . -run 'Destination\|Import\|SourceContext\|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` | | Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` | | Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| 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'` |
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` | | Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff\|Explain\|History\|Secret\|Independent'`, `go test . -run 'Diff\|FacadeInspection\|PublicFacadeBoundary\|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | | 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` | | Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` | | Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |

View File

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

View File

@@ -20,14 +20,19 @@ Implemented component owners:
inheritance and decoding, before application resolution. The engine also owns inheritance and decoding, before application resolution. The engine also owns
shared source-version checking with sanitized declaration evidence. shared source-version checking with sanitized declaration evidence.
Explicit import discovery supports declared paths, directory searches, and Explicit import discovery supports declared paths, directory searches, and
globs; load-wide defaults and source-local empty-selection evidence cross the globs; root-owned defaults are converted to the engine's single defaults input
root facade without changing acquisition order. Import and inheritance and snapshotted during load preflight before acquisition, while source-local
resolution share dependency-ready scheduling within their composition scopes. empty-selection evidence crosses the facade without changing acquisition
order. The root constructs placement directly on engine options and inputs
using explicit enum conversion; public types and fields remain root-owned.
Import and inheritance resolution share dependency-ready scheduling within
their composition scopes.
It retains each source's effective placement alongside physical source It retains each source's effective placement alongside physical source
identity and owns schema identity and owns schema
inspection, lifecycle coordination, callback auditing, inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and provenance/history, passive source-context retention, secret resolution and
bootstrap, safe inspection rendering, and effective-value comparison over bootstrap, operation-local secret failure-location attribution, safe
inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package. frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records, - Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
@@ -87,3 +92,96 @@ operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-disco
records explicit main-file discovery. The [import internals](imports.md) records explicit main-file discovery. The [import internals](imports.md)
document candidate and namespace identities, discovery and scheduling, and the document candidate and namespace identities, discovery and scheduling, and the
bounded algorithms. bounded algorithms.
## Implementation flow
The root facade converts caller-owned values into engine-owned requests. Its
`ImportDefaults` conversion maps fields without taking ownership; engine load
preflight snapshots the defaults once before source acquisition or callbacks.
Ordinary and grouped loading then carry that read-only value through their
normalized `loadRequest` and acquisition policy, while declaration resolution
copies values needed by its independently owned effective policy. Placement is
constructed directly on the engine's options and input records after explicit
root-to-engine enum conversion. See
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
version, default-import, and override policy stays distinct from each input's
source, filesystem, destination, and import settings.
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
input is acquired under that policy; grouped requests retain acquired underlay
scopes until their tier is composed in
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
share one assembly handoff that applies collection declarations and projects
only that tier's source/version evidence; the caller keeps main-version
protection and its projection explicit. Live
filesystem access handles are confined to acquisition and closed there. Resolved
filesystem facts used to authorize access are separate from passively retained
resource contexts. Non-import validation retains those facts on `assembledInput`
for passive-context construction and ordinary-overlay handling: see
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
[`source_context.go`](../../internal/engine/source_context.go).
Import responsibilities are split across candidate selection and path
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
source reading and traversal admission in
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
and projection of acquired evidence into final source coordinates in
[`import_projection.go`](../../internal/engine/import_projection.go). The
[import internals](imports.md) page describes the boundaries and identities.
After acquisition handles are closed, grouped composition and inheritance run on
the retained ordered documents. `inheritance.go` coordinates traversal and
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
dependency-ready templates;
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
prepares child and parent inputs,
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
returns the combined value with its origin, policy, sequence, and diagnostic
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
accounts for and finalizes the result. Projection and schema work have separate
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
descriptor caching and type policy, and
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
those descriptors for typed decoding.
History and inspection use one private evidence owner. The
[`history_model.go`](../../internal/engine/history_model.go) builder receives
baseline, source-composition, inheritance, typed-checkpoint, and CLI evidence
in [`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go),
before application resolution begins. The callback retains its separate fixed
entry view; it does not read a builder that continues to change. After successful
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
adds the original resolution records and their net replay groups. At the existing
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
freezes that builder and counts the retained document roots before secret lookup
and validation.
The frozen model is shared by `Metadata` and `Inspection`. Its shape-aware
selection in [`history_select.go`](../../internal/engine/history_select.go)
drives metadata history, explanation, and historical-path membership; the model
also supplies historical diagnostic locations and sensitivity/dependency
evidence. `Inspection` replays selected payloads to produce detached, safely
rendered values, while metadata queries return detached attribution records.
History and explanation behavior is covered by focused `history_*_test.go`
tests alongside metadata, secret, inheritance, and facade integration tests.
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
covered by `imports_test.go`, `load_request_test.go`, and focused source,
discovery, and destination tests; inheritance by the inheritance test files;
reflection behavior by `schema_test.go`, `projection_test.go`, and
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
copying, using the shared descriptors and per-operation traversal helpers.
Each schema operation builds its private reflection descriptor graph with
operation-local caching; there is no global mutable descriptor cache. Decoding,
projection, and cloning reuse these descriptors so field bindings agree. The
public reference describes the resulting type, output, and ownership behavior;
this descriptor storage is an implementation detail.

View File

@@ -1,16 +1,23 @@
# 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 a source-only Go module. The immutable annotated tag and A release publishes the Go source under a new Git tag, with a matching release
matching checked-in release note are the deliverables; there is no binary, note committed in the repository. The tag is annotated: it includes the release
container image, generated package, or separate registry publication. note text and points to the released commit. Published tags and contents are
immutable. There is no binary, container image, generated package, or separate
registry publication.
A **candidate** is the committed revision you intend to release. The
**authoritative remote** is the repository where ConfigKit publishes releases;
its exact push URL is listed below. The **consumer minimum** is the oldest Go
version supported for applications using ConfigKit.
## Release identity and support ## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit` - Module: `go.maximumdirect.net/configkit`
- Primary branch: `main` - Primary branch: `main`
- Authoritative remote: `origin` - Authoritative remote: `origin`
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git` - Authoritative push URL: `ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git`
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases - Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
- Minimum Go version: the first stable patch of the selected Go series, or a - Minimum Go version: the first stable patch of the selected Go series, or a
higher patch when required by a dependency or specific correctness fix, as higher patch when required by a dependency or specific correctness fix, as
@@ -20,10 +27,15 @@ container image, generated package, or separate registry publication.
- Best-effort targets: macOS amd64 and arm64, under the - 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)
Release validation runs one offline race-enabled Go suite, one full vet pass, Release validation runs the offline Go test suite once with the race detector,
and CGO-disabled package compilation for both Linux targets. macOS and CI are then a full vet pass and package compilation for both Linux targets with cgo
disabled (`CGO_ENABLED=0`). macOS and CI are
not release gates; do not wait for or poll CI. not release gates; do not wait for or poll CI.
The vanity module path is the name applications put in Go imports. Source/tag
browser links and public Go retrieval use the Forge HTTPS repository
independently of SSH publication.
## 1. Select Go and prepare the tool ## 1. Select Go and prepare the tool
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
@@ -33,10 +45,10 @@ For a patch release, retain the series recorded in `go.mod` and leave its consum
minimum unchanged. Follow the minimum unchanged. Follow the
[toolchain selection policy](policy/release.md#configkit-toolchain-selection). [toolchain selection policy](policy/release.md#configkit-toolchain-selection).
Put the selected series' latest locally available compatible patch first on Choose the latest compatible patch installed locally in that Go series. Put
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build its `go` executable first on `PATH` and ensure it meets the minimum in
ReleaseKit and run its child validation commands. Set local toolchain selection `go.mod`. The same `go` executable must build ReleaseKit and run its child
and verify it: validation commands. Set local toolchain selection and verify it:
```sh ```sh
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
@@ -61,8 +73,17 @@ use that required version instead and document the reason. When the series is
unchanged, leave the minimum unchanged unless a minor or major release unchanged, leave the minimum unchanged unless a minor or major release
deliberately changes it under the deliberately changes it under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain). [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 For example:
separate requirement to validate with Go 1.26.0.
| Release situation | Go used for validation | Consumer minimum |
| --- | --- | --- |
| Patch release in the 1.26 series | Latest compatible patch installed locally, such as 1.26.8 | Keep the existing `go.mod` minimum unchanged. |
| Minor/major release staying in 1.26 | Latest compatible patch installed locally | Normally retain the minimum; any deliberate change follows the linked policy. |
| Minor/major release adopting a newer installed stable series | Latest compatible patch installed locally in that series | Normally declare that series' initial stable patch, or the higher patch required by a dependency or identified fix. |
Using Go 1.26.8 for validation can therefore leave `go 1.26.0` unchanged. Users
need the declared minimum, while maintainers validate with their selected patch.
There is no separate requirement to validate with Go 1.26.0.
Prepare dependencies whether or not the minimum changes: Prepare dependencies whether or not the minimum changes:
@@ -70,7 +91,8 @@ Prepare dependencies whether or not the minimum changes:
go mod download go mod download
``` ```
Build the tool selected by `go.mod` into temporary storage outside the checkout. Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
the checkout.
Ensure `$TMPDIR` names a system temporary location, not the repository. These Ensure `$TMPDIR` names a system temporary location, not the repository. These
commands never install another ReleaseKit version: commands never install another ReleaseKit version:
@@ -82,8 +104,8 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version "$releasekit_bin" version
``` ```
The module-selected executable is ReleaseKit v0.4.1. Use the built executable This repository selects ReleaseKit v0.4.2. Use the compiled program above for
for publication to preserve its exit status and publication-state diagnostics. publication so its exit status and publication report reach you unchanged.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands `go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
executable first on `PATH` through the publication command. executable first on `PATH` through the publication command.
@@ -91,7 +113,7 @@ executable first on `PATH` through the publication command.
Verify access to the authoritative remote without displaying credentials: Verify access to the authoritative remote without displaying credentials:
```sh ```sh
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main git ls-remote ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD refs/heads/main
``` ```
Publication requires explicit authorization. Local implementation commits do Publication requires explicit authorization. Local implementation commits do
@@ -99,10 +121,10 @@ not authorize a push or release.
## 2. Prepare and commit the candidate ## 2. Prepare and commit the candidate
Choose an unused stable version locally and on the authoritative remote. Compare Choose a stable version that has not been used locally or on the publication
the candidate with the previous tag and review exported Go signatures and remote. Compare the candidate with the previous tag. Review public Go APIs and
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go value ownership, YAML and resolution rules, diagnostic contracts, the minimum
version, and platform support. Review maintained consumer examples and the Go version, and platform support. Review maintained consumer examples and the
migration guidance. Explain any consumer-minimum increase and required consumer migration guidance. Explain any consumer-minimum increase and required consumer
action under the action under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain). [consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
@@ -111,7 +133,7 @@ Create `docs/releases/<version>.md`. Its first line must be exactly
`# ConfigKit <version>`, and it must end with the license and attribution footer: `# ConfigKit <version>`, and it must end with the license and attribution footer:
```markdown ```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md) [COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/docs/oss/attribution.md)
``` ```
Replace `VERSION` with the full tag and confirm the three target files exist in Replace `VERSION` with the full tag and confirm the three target files exist in
@@ -127,7 +149,7 @@ Push only that committed candidate to the literal authoritative URL. This does
not follow local tags: not follow local tags:
```sh ```sh
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main git push --no-follow-tags ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD:refs/heads/main
``` ```
ReleaseKit requires authoritative `main` to equal this candidate before it ReleaseKit requires authoritative `main` to equal this candidate before it
@@ -139,32 +161,40 @@ candidate to `main`, and invoke validation again.
## 4. Publish the committed candidate ## 4. Publish the committed candidate
With the authorized candidate already on authoritative `main`, invoke the Once publication is authorized and the candidate is on the publication remote's
module-selected native executable exactly once: `main`, run the compiled ReleaseKit program once to validate and publish it:
```sh ```sh
"$releasekit_bin" publish vMAJOR.MINOR.PATCH "$releasekit_bin" publish vMAJOR.MINOR.PATCH
``` ```
Replace the placeholder with the selected version. ReleaseKit checks the Replace the placeholder with the selected version. ReleaseKit checks the
effective push URL against `expected_push_url`, verifies the candidate on push URL Git will actually use, after URL rewrites, against `expected_push_url`.
It verifies the candidate on
`main`, and checks the release-note H1 for the product and version. It then `main`, and checks the release-note H1 for the product and version. It then
runs the selected race-enabled standard suite once with test-time vet disabled, 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 one full vet pass, and CGO-disabled package compilation for Linux amd64 and
arm64. It publishes only the annotated tag for that candidate. Confirmed arm64. It publishes only the annotated tag for that candidate. Confirmed
publication of the intended tag completes the release. The tag points to the 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. captured commit and uses the complete checked-in note as its annotation message.
The optional `check` Use the optional `check` command for diagnosis when useful. It validates without
command is available for diagnosis but is not a separate required pass: publishing and is not required before `publish`, which performs its own checks:
```sh ```sh
"$releasekit_bin" check vMAJOR.MINOR.PATCH "$releasekit_bin" check vMAJOR.MINOR.PATCH
``` ```
Status 0 confirms publication, status 1 reports a known publication failure or For `publish`, interpret the program's exit status as follows:
conflict, status 2 reports usage, profile, or version errors, and status 3 means
the push outcome remains unknown. Preserve both the native exit status and its | Status | Meaning | Next action |
diagnostics. | --- | --- | --- |
| `0` | Publication succeeded. | The release is complete. Optional verification can follow. |
| `1` | A known publication failure or conflict was reported. | Read the report and correct the stated problem. Preserve any published tag. |
| `2` | The command, profile, or version is invalid. | Correct the command or configuration before trying again. |
| `3` | The tag push outcome is still unknown. | Follow the recovery procedure below before retrying or declaring success. |
Keep both the program's exit status and its diagnostic report. Using `go run`
would lose the distinction between its nonzero exit statuses.
The command does not push or repair `main`, wait for CI, or publish binaries or 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 a hosted release page. A successful intended-tag publication does not require
@@ -173,12 +203,17 @@ remote read-back or consumer retrieval; those checks remain optional.
## 5. Recover from a failed or uncertain tag push ## 5. Recover from a failed or uncertain tag push
The executable reports the publication state, phase, version, candidate commit, The executable reports the publication state, phase, version, candidate commit,
tag-object ID, effective endpoint, and error on standard error. It performs one tag-object ID, effective endpoint, and error on standard error. After a failed
bounded authoritative reconciliation after a failed tag push. Exit status 3 tag push, it makes one bounded check of the exact publication remote to find out
whether the tag arrived. Exit status 3
means the tag outcome remains unknown; do not blindly retry or move a tag. 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 An annotated tag is a Git object containing the release note and a pointer to
tag-object ID from the report. Copy the captured endpoint into the variable the source commit. Its tag-object ID identifies that complete object. Checking
only the commit ID would not establish that the intended tag object arrived.
For an uncertain result, use the exact endpoint (remote URL), 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, below using normal shell quoting. Check that Git still resolves it to itself,
then query the authoritative refs once: then query the authoritative refs once:
@@ -195,7 +230,8 @@ cannot establish absence at the captured endpoint.
Compare the `main` object ID with the reported candidate commit and the tag 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 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 object itself. A peeled commit is the source commit reached by following that
tag; it is a different identity. If the tag object matches, the
release is published. If the query fails, the result is conflicting, or 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 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 endpoint is reachable, the tag is confirmed absent, and `main` still equals
@@ -228,8 +264,8 @@ consumer_dir=$(mktemp -d)
( (
cd "$consumer_dir" cd "$consumer_dir"
go mod init example.invalid/configkit-release-check go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version" GOWORK=off GOPROXY=direct go get "go.maximumdirect.net/configkit@$release_version"
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit GOWORK=off go list -m -f '{{.Path}} {{.Version}}' go.maximumdirect.net/configkit
) )
``` ```

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

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

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

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
# Potential Future Features # Potential Future Work
This document lists potential enhancements for consideration in future ConfigKit This document lists potential enhancements and maintenance work for consideration
releases. Entries are not accepted contracts, implementation plans, or release in future ConfigKit releases. Entries are not accepted contracts, implementation
commitments. Adopting a candidate requires an explicit design decision and a plans, or release commitments. Adopting a candidate requires an explicit design
separate plan. Significant architectural exclusions and their rationale belong decision and a separate plan. Significant architectural exclusions and their rationale belong
in ADRs; this document does not maintain a list of rejected proposals. in ADRs; this document does not maintain a list of rejected proposals.
## 1. Recursive body merging for identified records ## 3. Recursive body merging for identified records
Status: deferred. The implemented Status: deferred. The implemented
[collection policy](../config.md#strict-document-composition) uses `upsert` to [collection policy](../config.md#strict-document-composition) uses `upsert` to
@@ -20,7 +20,7 @@ presence and ordering, and how provenance, history, sensitivity, and resource
context follow partially retained records. Any change requires a new decision and context follow partially retained records. Any change requires a new decision and
implementation plan; this entry does not alter current upsert behavior. implementation plan; this entry does not alter current upsert behavior.
## 2. Required version declarations for selected sources ## 4. Required version declarations for selected sources
Status: deferred. PromptKit requested a way to require version declarations on Status: deferred. PromptKit requested a way to require version declarations on
a selected subset of source files while permitting other fragments to omit them. a selected subset of source files while permitting other fragments to omit them.
@@ -56,7 +56,7 @@ compatible version or replace final configuration validation.
behavior for absent optional inputs and preserve source-local diagnostics, behavior for absent optional inputs and preserve source-local diagnostics,
inspection evidence, and existing sensitivity handling. inspection evidence, and existing sensitivity handling.
## 3. Performance experiments ## 5. Performance experiments
Status: deferred pending evidence. The completed performance audit identified Status: deferred pending evidence. The completed performance audit identified
four internal optimization candidates for separate investigation: four internal optimization candidates for separate investigation:
@@ -83,3 +83,22 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
across loads without introducing aliasing? across loads without introducing aliasing?
- What cache lifetime and concurrency design would make schema reuse worthwhile - What cache lifetime and concurrency design would make schema reuse worthwhile
without excessive retained memory? without excessive retained memory?
## 6. Public path comparison
Status: deferred pending a concrete consumer need. This preserves A2 from the
completed API review at `68400ff:docs/roadmap/review.md`.
`Path.String()` is a JSON Pointer spelling, not a complete semantic identity:
an explicit numeric mapping key and a sequence index can share it. The public
type is opaque and currently non-comparable.
If consumers need comparison, consider a small exact-representation operation
rather than exposing mutable segments or introducing a pattern language. A
proposal must distinguish exact segment representation, including parsed versus
explicit segments and invalid state, from resolution against a document. Pointer
string equality must not be presented as semantic equality.
Keeping `Path` solely as an opaque query/building value remains an alternative.
No current contract changes or implementation commitment follow from this entry;
a comparison operation can be added compatibly later.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type workingDirectoryPresetConfig struct { type workingDirectoryPresetConfig struct {
@@ -40,6 +40,7 @@ func Example_workingDirectoryPreset() {
fmt.Println(err) fmt.Println(err)
return return
} }
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path)) source, _ := result.Source("source-1")
fmt.Println(result.Config.Name, filepath.Base(source.Path))
// Output: example service.yml // Output: example service.yml
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// SequenceCatalogConfig is a source-backed prompt catalog with an application // SequenceCatalogConfig is a source-backed prompt catalog with an application
@@ -43,7 +43,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root)) filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem} filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
mainSearch := configkit.SearchConfig("", configkit.Searches( mainSearch := configkit.SearchConfig("", configkit.Searches(
configkit.Paths(absoluteMain), configkit.SearchPaths(absoluteMain),
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{ configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))}, Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
}), }),
@@ -54,7 +54,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
}, },
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error { Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
configuration.ResolvedLabel = "for:" + configuration.RunLabel configuration.ResolvedLabel = "for:" + configuration.RunLabel
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization, resolution.RecordNormalization(configkit.Keys("resolved_label"),
"derive the resolved label from the selected run label", configkit.Keys("run_label")) "derive the resolved label from the selected run label", configkit.Keys("run_label"))
return nil return nil
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,14 +93,14 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
names := []string{"config.yml"} names := []string{"config.yml"}
groups := []FilenameGroup{CustomFilenames(names...)} groups := []FilenameGroup{CustomFilenames(names...)}
directoryOptions := []DirectorySearchOptions{{Filenames: groups}} directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath)) search := Searches(SearchPaths(paths...), SearchDirectory(root, directoryOptions...), SearchPaths(laterPath))
source := SearchConfig("", search, SearchOptions{Required: true}) source := SearchConfig("", search, SearchOptions{Required: true})
paths[0] = laterPath paths[0] = laterPath
names[0] = "later.yml" names[0] = "later.yml"
groups[0] = CustomFilenames("later.yml") groups[0] = CustomFilenames("later.yml")
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml") directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
search = Paths(laterPath) search = SearchPaths(laterPath)
result, err := Load[facadeDiscoveryConfig](source) result, err := Load[facadeDiscoveryConfig](source)
if err != nil || result.Config.Value != "selected" { if err != nil || result.Config.Value != "selected" {
@@ -110,7 +110,7 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
t.Fatalf("ordered search sources = %#v", sources) t.Fatalf("ordered search sources = %#v", sources)
} }
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true})) _, err = Load[facadeDiscoveryConfig](SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
var loadErr *LoadError var loadErr *LoadError
if !errors.As(err, &loadErr) { if !errors.As(err, &loadErr) {
t.Fatalf("required exhausted search error = %v, want *LoadError", err) t.Fatalf("required exhausted search error = %v, want *LoadError", err)
@@ -131,7 +131,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n") mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n") underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
result, err := LoadLayered( result, err := LoadLayered(
Input{Source: SearchConfig("", Paths(mainPath))}, Input{Source: SearchConfig("", SearchPaths(mainPath))},
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{ Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
Filenames: []FilenameGroup{CustomFilenames("base.yml")}, Filenames: []FilenameGroup{CustomFilenames("base.yml")},
@@ -142,7 +142,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
t.Fatalf("layered search = %#v, %v", result, err) t.Fatalf("layered search = %#v, %v", result, err)
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase || if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerMain ||
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay { sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
t.Fatalf("layered search sources = %#v", sources) t.Fatalf("layered search sources = %#v", sources)
} }
@@ -157,7 +157,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
filesystem := &FilesystemContext{Root: root} filesystem := &FilesystemContext{Root: root}
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem} options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
selected, err := Load(SearchConfig("", Paths(mainPath)), options) selected, err := Load(SearchConfig("", SearchPaths(mainPath)), options)
if err != nil || selected.Config.Value != "overlay" { if err != nil || selected.Config.Value != "overlay" {
t.Fatalf("selected search with root-only context = %#v, %v", selected, err) t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
} }
@@ -171,7 +171,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
t.Fatalf("selected file base context = %#v, %v", mainContext, ok) t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
} }
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml"))) missing := SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")))
searchedAbsent, err := Load(missing, options) searchedAbsent, err := Load(missing, options)
if err != nil { if err != nil {
t.Fatalf("exhausted search with root-only overlay context: %v", err) t.Fatalf("exhausted search with root-only overlay context: %v", err)
@@ -201,7 +201,7 @@ func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n") inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
outsideRoot := t.TempDir() outsideRoot := t.TempDir()
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n") outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
search := SearchConfig("", Paths(outside, inside)) search := SearchConfig("", SearchPaths(outside, inside))
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}}) _, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
var loadErr *LoadError var loadErr *LoadError
@@ -228,7 +228,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n") selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
writeFacadeImportFile(t, root, "later.yml", "value: later\n") writeFacadeImportFile(t, root, "later.yml", "value: later\n")
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0 defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{ result, err := Load(SearchConfig("", SearchPaths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
Definition: Definition[facadeDiscoveryLifecycleConfig]{ Definition: Definition[facadeDiscoveryLifecycleConfig]{
Defaults: func() facadeDiscoveryLifecycleConfig { Defaults: func() facadeDiscoveryLifecycleConfig {
defaultsCalls++ defaultsCalls++
@@ -237,7 +237,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
resolveCalls++ resolveCalls++
configuration.Resolved = "from:" + configuration.Value configuration.Resolved = "from:" + configuration.Value
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value")) resolution.RecordNormalization(Keys("resolved"), "derive from selected value", Keys("value"))
return nil return nil
}, },
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ }, Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
@@ -272,7 +272,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
Destination: Keys("items"), Destination: Keys("items"),
PlacementMode: PlacementModeSequenceItem, PlacementMode: PlacementModeSequenceItem,
} }
selected, err := Load(SearchConfig("", Paths(selectedPath)), options) selected, err := Load(SearchConfig("", SearchPaths(selectedPath)), options)
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" { if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
t.Fatalf("selected sequence item = %#v, %v", selected, err) t.Fatalf("selected sequence item = %#v, %v", selected, err)
} }
@@ -283,7 +283,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
defaults := func() facadeDiscoveryPlacementConfig { defaults := func() facadeDiscoveryPlacementConfig {
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}} return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
} }
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{ absent, err := Load(SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults}, Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
Destination: options.Destination, Destination: options.Destination,
PlacementMode: options.PlacementMode, PlacementMode: options.PlacementMode,

View File

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

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type equivalentConfig struct { type equivalentConfig struct {
@@ -23,6 +23,9 @@ type equivalentConfig struct {
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) { func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n" const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
bytesData := []byte(yaml)
bytesSource := configkit.Bytes("memory", bytesData)
bytesData[0] = 'X'
hostPath := filepath.Join(t.TempDir(), "main.yml") hostPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil { if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
}{ }{
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile}, {"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem}, {"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes}, {"bytes", bytesSource, context, configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader}, {"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
{"mapping", configkit.Mapping("programmatic", map[string]any{ {"mapping", configkit.Mapping("programmatic", map[string]any{
"version": uint64(1), "name": "retained", "enabled": false, "count": 0, "version": uint64(1), "name": "retained", "enabled": false, "count": 0,
@@ -66,7 +69,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
} }
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} { for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
info, ok := result.Metadata().Lookup(configkit.Keys(field)) info, ok := result.Metadata().Lookup(configkit.Keys(field))
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") { if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerMain || info.Null != (field == "optional") {
t.Errorf("%s input info = %#v, %v", field, info, ok) t.Errorf("%s input info = %#v, %v", field, info, ok)
} }
} }
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed { if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("reader re-consumption = %s", got) t.Fatalf("reader re-consumption = %s", got)
} }
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
readerCopy := copiedReader
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
t.Fatal(err)
}
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("copied reader re-consumption = %s", got)
}
} }
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) { func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
@@ -120,7 +132,7 @@ func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
} }
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{ result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}, Definition: configkit.Definition[config]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }},
}) })
if err != nil || result.Config.Secret != "new-secret-canary" { if err != nil || result.Config.Secret != "new-secret-canary" {
t.Fatalf("secret load = %#v, %v", result, err) t.Fatalf("secret load = %#v, %v", result, err)

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
) )
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) { func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
@@ -95,7 +93,8 @@ main: main
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) { func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
type config struct { type config struct {
Value string `yaml:"value"` Value string `yaml:"value"`
Hidden string `yaml:"hidden"`
} }
directory := t.TempDir() directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit: mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
@@ -103,17 +102,23 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
- dir: snippets - dir: snippets
`) `)
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n") writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
writeFacadeImportFile(t, directory, "snippets/.hidden.cfg", "hidden: imported\n")
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true}) unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
if err != nil || unrestricted.Config.Value != "imported" { if err != nil || unrestricted.Config.Value != "imported" {
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err) t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
} }
includeHidden := false
visibleOnly, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{IncludeHidden: &includeHidden}})
if err != nil || visibleOnly.Config.Value != "imported" || visibleOnly.Config.Hidden != "" {
t.Fatalf("hidden-file default = %#v, %v", visibleOnly, err)
}
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}}) empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
if err != nil || empty.Config.Value != "" { if err != nil || empty.Config.Value != "" {
t.Fatalf("empty pattern = %#v, %v", empty, err) t.Fatalf("empty pattern = %#v, %v", empty, err)
} }
emptySources := empty.Sources() emptySources := empty.Sources()
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone { if len(emptySources) != 1 || len(emptySources[0].EmptyImportSelections) != 1 || emptySources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("empty-pattern evidence = %#v", emptySources) t.Fatalf("empty-pattern evidence = %#v", emptySources)
} }
required := true required := true
@@ -122,23 +127,64 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("required empty selection error = %T %v", err, err) t.Fatalf("required empty selection error = %T %v", err, err)
} }
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 { if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
t.Fatalf("required empty selection was recorded as optional: %#v", sources) t.Fatalf("required empty selection was recorded as optional: %#v", sources)
} }
}
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit: func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
type config struct {
Version string `yaml:"version"`
Imported string `yaml:"imported"`
}
for _, layered := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
configkit:
imports: imports:
- dir: recursive-files - dir: parts
`) `)
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n") writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
recursive := true
patterns := []string{"*.cfg"} recursive := true
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}}) patterns := []string{"*.cfg"}
recursive = false definition := Definition[config]{Version: &VersionRule{
patterns[0] = "[" Path: Keys("version"),
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions) Check: func(version Version, _ *Validation) error {
if err != nil || loadedEngine.Config.Value != "selected" { if version != (Version{Present: true, Text: "v1"}) {
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err) t.Errorf("version callback received %#v", version)
}
recursive = false
patterns[0] = "*.skip"
return nil
},
}}
load := func() (*Result[config], error) {
defaults := ImportDefaults{Recursive: &recursive, Pattern: patterns}
if layered {
return LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, ImportDefaults: defaults,
})
}
return Load(File(mainPath), Options[config]{
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, Imports: true, ImportDefaults: defaults,
})
}
first, err := load()
if err != nil || first.Config.Imported != "selected" {
t.Fatalf("load with pre-callback defaults = %#v, %v", first, err)
}
if recursive || patterns[0] != "*.skip" {
t.Fatalf("version callback did not mutate caller defaults: recursive=%v pattern=%q", recursive, patterns[0])
}
second, err := load()
if err != nil || second.Config.Imported != "" {
t.Fatalf("subsequent load reused earlier defaults: %#v, %v", second, err)
}
})
} }
} }
@@ -191,15 +237,15 @@ value: loaded
callbackPath := "" callbackPath := ""
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
first, ok := resolution.Source("source-1") first, ok := resolution.Source("source-1")
if !ok || len(first.ImportSelections) != 1 { if !ok || len(first.EmptyImportSelections) != 1 {
t.Fatalf("resolution source = %#v, %v", first, ok) t.Fatalf("resolution source = %#v, %v", first, ok)
} }
first.ImportSelections[0].Path = Keys("callback mutation") first.EmptyImportSelections[0].Path = Keys("callback mutation")
second, ok := resolution.Source("source-1") second, ok := resolution.Source("source-1")
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" { if !ok || second.EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("resolution snapshot changed after mutation: %#v", second) t.Fatalf("resolution snapshot changed after mutation: %#v", second)
} }
callbackPath = second.ImportSelections[0].Path.String() callbackPath = second.EmptyImportSelections[0].Path.String()
return nil return nil
}}}) }}})
if err != nil { if err != nil {
@@ -209,11 +255,11 @@ value: loaded
t.Fatalf("callback path = %q", callbackPath) t.Fatalf("callback path = %q", callbackPath)
} }
sources := loaded.Sources() sources := loaded.Sources()
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory { if len(sources) != 1 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Location.SourceID != sources[0].ID || sources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("result source evidence = %#v", sources) t.Fatalf("result source evidence = %#v", sources)
} }
sources[0].ImportSelections[0].Path = Keys("result mutation") sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("result snapshot changed after mutation: %q", got) t.Fatalf("result snapshot changed after mutation: %q", got)
} }
@@ -229,11 +275,11 @@ value: loaded
t.Fatalf("failed load error = %T %v", err, err) t.Fatalf("failed load error = %T %v", err, err)
} }
errorSources := loadError.Sources() errorSources := loadError.Sources()
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" { if len(errorSources) != 1 || len(errorSources[0].EmptyImportSelections) != 1 || errorSources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("error source evidence = %#v", errorSources) t.Fatalf("error source evidence = %#v", errorSources)
} }
errorSources[0].ImportSelections[0].Path = Keys("error mutation") errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("error snapshot changed after mutation: %q", got) t.Fatalf("error snapshot changed after mutation: %q", got)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

@@ -3,18 +3,21 @@ package configkit
import ( import (
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine" "go.maximumdirect.net/configkit/internal/engine"
) )
// ValueSnapshot is a detached, deterministic YAML representation of one value. // ValueSnapshot describes a saved value as consistently formatted YAML text.
// Present distinguishes absence or deletion from an explicit null value. // It is a copy; changing it cannot affect later inspection. Present is false for
// absence or deletion, while explicit null is present. Redacted reports whether
// the value or a descendant was hidden.
type ValueSnapshot struct { type ValueSnapshot struct {
Present bool Present bool
Redacted bool Redacted bool
YAML string YAML string
} }
// ValueChange describes one explicit definition or audited domain operation. // ValueChange shows values before and after an input contribution or recorded
// application operation, together with its origin, reasons, and dependencies.
type ValueChange struct { type ValueChange struct {
Before ValueSnapshot Before ValueSnapshot
After ValueSnapshot After ValueSnapshot
@@ -29,7 +32,8 @@ type SecretReferenceInfo struct {
Name string Name string
} }
// Explanation is a detached safe account of one effective or historical path. // Explanation describes a path's final value and earlier contributions, with
// sensitive values hidden. It is a copy that cannot alter later inspection.
type Explanation struct { type Explanation struct {
Path Path Path Path
Effective ValueSnapshot Effective ValueSnapshot
@@ -38,9 +42,10 @@ type Explanation struct {
SecretReference *SecretReferenceInfo SecretReference *SecretReferenceInfo
} }
// Explain returns safe effective and chronological values for a known final or // Explain returns the saved final value and earlier values for a known current
// historical path. Structural inheritance appears between source applications // or historical path, hiding sensitive information. History follows processing
// and typed reconciliation. Unknown and malformed paths return false. // order: inheritance appears after source combination and before the transition
// to typed values. 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
@@ -49,8 +54,9 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
return fromEngineExplanation(value), ok return fromEngineExplanation(value), ok
} }
// WriteEffective writes deterministic redacted YAML from the frozen resolved // WriteEffective writes consistently formatted YAML for the saved final
// snapshot. The output is intended for inspection and is not promised reloadable. // configuration, with sensitive values hidden. Later changes to Result.Config
// do not change the output. It is for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output. // Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error { func (r *Result[T]) WriteEffective(output io.Writer) error {
if r == nil { if r == nil {
@@ -61,7 +67,7 @@ func (r *Result[T]) WriteEffective(output io.Writer) error {
} }
// WriteExplanation writes a deterministic human-readable rendering of Explain. // WriteExplanation writes a deterministic human-readable rendering of Explain.
// Unknown paths produce no output. Writer failures may leave partial output. // Unknown paths return an error without output. Writer failures may leave partial output.
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error { func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
if r == nil { if r == nil {
var inspection *engine.Inspection var inspection *engine.Inspection

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"maps" "maps"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
// composedDocument is the private handoff between source assembly and later // composedDocument is the private handoff between source assembly and later
@@ -43,12 +43,20 @@ type sequenceHistoryContribution struct {
// assemblyAccumulator owns one mutable working document. Acquired trees held by // assemblyAccumulator owns one mutable working document. Acquired trees held by
// history applications are separate from its root and remain unchanged. // history applications are separate from its root and remain unchanged.
type assemblyAccumulator struct { type assemblyAccumulator struct {
current *composedDocument current *composedDocument
index mappingIndex index mappingIndex
collectionPolicyRetirement *collectionPolicyRetirementIndex
} }
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator { func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
return &assemblyAccumulator{current: owned} policies := collectionPolicyScope{}
if owned != nil {
policies = owned.collectionPolicies
}
return &assemblyAccumulator{
current: owned,
collectionPolicyRetirement: newCollectionPolicyRetirementIndex(policies),
}
} }
// cloneComposedDocument detaches mutable composition and provenance state; // cloneComposedDocument detaches mutable composition and provenance state;
@@ -215,7 +223,7 @@ func composeDocumentsWithCollectionPolicy(
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned) rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
} }
for sourceIndex, input := range inputs { for sourceIndex, input := range inputs {
application := historyApplication{layer: LayerBase, value: input} application := historyApplication{layer: LayerMain, value: input}
for _, assigned := range rangesBySource[sourceIndex] { for _, assigned := range rangesBySource[sourceIndex] {
pointer := assigned.path.String() pointer := assigned.path.String()
mode := sequenceHistoryAppend mode := sequenceHistoryAppend
@@ -247,7 +255,7 @@ func composeDocumentsWithCollectionPolicy(
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) { func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
pointer := path.String() pointer := path.String()
if _, present := origins[pointer]; !present { if _, present := origins[pointer]; !present {
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}} origins[pointer] = Origin{Layer: LayerMain, Locations: []Location{publicLocation(node.Location)}}
} }
switch node.Kind { switch node.Kind {
case document.Mapping: case document.Mapping:
@@ -305,14 +313,10 @@ func resolveStrictCollectionPolicies(
valuePresent := make(map[string]bool) valuePresent := make(map[string]bool)
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations)) declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
for _, claim := range claims { for _, claim := range claims {
if len(lower.collections) != 0 {
valuePresent[claim.path.String()] = true
}
if claim.kind != document.Sequence {
continue
}
key := claim.path.String() key := claim.path.String()
if !seen[key] { valuePresent[key] = true
_, hasLower := lower.collections[key]
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
seen[key] = true seen[key] = true
paths = append(paths, claim.path.clone()) paths = append(paths, claim.path.clone())
} }
@@ -327,21 +331,18 @@ func resolveStrictCollectionPolicies(
} }
identityFieldsByPath := make(map[string][]string) identityFieldsByPath := make(map[string][]string)
effectiveLower := lower replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
if len(lower.collections) != 0 { lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy) var collectionPolicies collectionPolicyScope
}
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
var diagnostics []Diagnostic var diagnostics []Diagnostic
for _, path := range paths { for _, path := range paths {
key := path.String() key := path.String()
currentDeclarations := declarationsByPath[key] currentDeclarations := declarationsByPath[key]
lowerEntry, hasLower := effectiveLower.collections[key] lowerPolicyValue, hasLower := lowerView.at(path)
var lowerPolicy *collectionPolicy var lowerPolicy *collectionPolicy
if hasLower { if hasLower {
copy := cloneCollectionPolicy(lowerEntry.policy) lowerPolicy = &lowerPolicyValue
lowerPolicy = &copy
} }
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path) applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
@@ -358,7 +359,7 @@ func resolveStrictCollectionPolicies(
if hasLower && !valuePresent[key] { if hasLower && !valuePresent[key] {
// A declaration without a new value changes policy metadata but // A declaration without a new value changes policy metadata but
// leaves retained record ownership untouched. // leaves retained record ownership untouched.
policyForPath = cloneCollectionPolicy(lowerEntry.policy) policyForPath = cloneCollectionPolicy(lowerPolicyValue)
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields) policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
policyForPath.identityPresent = resolution.policy.identityPresent policyForPath.identityPresent = resolution.policy.identityPresent
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation) policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
@@ -367,7 +368,7 @@ func resolveStrictCollectionPolicies(
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation) policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
} }
if collectionPolicies.collections == nil { if collectionPolicies.collections == nil {
collectionPolicies.collections = make(map[string]collectionPolicyEntry) collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
} }
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath} collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
} }
@@ -483,21 +484,6 @@ func allMappingClaims(claims []compositionClaim) bool {
return true return true
} }
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
for _, incomingEntry := range incoming.Mapping {
position := index.find(target, incomingEntry.Key)
if position < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
index.append(target, cloned)
continue
}
// Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
}
}
func documentEntryIndex(entries []document.Entry, key string) int { func documentEntryIndex(entries []document.Entry, key string) int {
for index := range entries { for index := range entries {
if entries[index].Key == key { if entries[index].Key == key {
@@ -527,7 +513,7 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
walkHistoryApplication(application, func(_, effective Path, node *document.Node) { walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
pointer := effective.String() pointer := effective.String()
origin := origins[pointer] origin := origins[pointer]
origin.Layer = LayerBase origin.Layer = LayerMain
origin.Locations = append(origin.Locations, publicLocation(node.Location)) origin.Locations = append(origin.Locations, publicLocation(node.Location))
origins[pointer] = origin origins[pointer] = origin
}) })

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"time" "time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
@@ -22,7 +22,6 @@ type decodeIssue struct {
} }
type inputPresence struct { type inputPresence struct {
null bool
location document.Location location document.Location
} }
@@ -91,19 +90,30 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
} }
} }
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
var elementBase reflect.Value terminal, ok := terminalDescriptor(descriptor)
if base.IsNil() { if !ok {
elementBase = reflect.Zero(descriptor.typeOf.Elem()) return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration type cannot represent a non-null value")}
} else {
elementBase = base.Elem()
} }
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context) var pointers []*typeDescriptor
currentBase := base
for current := descriptor; current != terminal; current = current.element {
pointers = append(pointers, current)
if currentBase.IsNil() {
currentBase = reflect.Zero(current.typeOf.Elem())
} else {
currentBase = currentBase.Elem()
}
}
decoded, issues := decodeValue(node, currentBase, terminal, path, schema, context)
if len(issues) != 0 { if len(issues) != 0 {
return reflect.Value{}, issues return reflect.Value{}, issues
} }
result := reflect.New(descriptor.typeOf.Elem()) for i := len(pointers) - 1; i >= 0; i-- {
result.Elem().Set(decoded) result := reflect.New(pointers[i].typeOf.Elem())
return result, nil result.Elem().Set(decoded)
decoded = result
}
return decoded, nil
} }
switch descriptor.kind { switch descriptor.kind {
@@ -392,10 +402,11 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
} }
func acceptsMapping(descriptor *typeDescriptor) bool { func acceptsMapping(descriptor *typeDescriptor) bool {
for descriptor != nil && descriptor.kind == descriptorPointer { terminal, ok := terminalDescriptor(descriptor)
descriptor = descriptor.element if !ok {
return false
} }
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic) return terminal.kind == descriptorStruct || terminal.kind == descriptorMap || terminal.kind == descriptorDynamic
} }
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) { func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
@@ -422,7 +433,7 @@ func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Val
} }
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) { func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location} presence[path.String()] = inputPresence{location: node.Location}
switch node.Kind { switch node.Kind {
case document.Mapping: case document.Mapping:
for _, entry := range node.Mapping { for _, entry := range node.Mapping {
@@ -437,7 +448,7 @@ func collectPresence(node *document.Node, path Path, presence map[string]inputPr
func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) { func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
if input, ok := presence[path.String()]; ok { if input, ok := presence[path.String()]; ok {
origins[path.String()] = Origin{Layer: LayerBase, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}} origins[path.String()] = Origin{Layer: LayerMain, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
} else { } else {
origins[path.String()] = Origin{Layer: LayerDefault} origins[path.String()] = Origin{Layer: LayerDefault}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -201,7 +201,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}}, sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
} }
item := func(label string, value map[string]any) Input { item := func(label string, value map[string]any) Input {
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem} return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem}
} }
options := func(inputs ...Input) LayeredOptions[map[string]any] { options := func(inputs ...Input) LayeredOptions[map[string]any] {
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}} return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
@@ -287,7 +287,7 @@ func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testi
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
observed.resolve++ observed.resolve++
configuration.Tenant.Derived = configuration.Tenant.Dynamic configuration.Tenant.Derived = configuration.Tenant.Dynamic
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic")) resolution.RecordNormalization(Keys("tenant", "derived"), "derive from mounted input", Keys("tenant", "dynamic"))
return nil return nil
}, },
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) { Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
@@ -425,7 +425,7 @@ func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
definition: Definition[config]{ definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
configuration.Tenant.Derived = configuration.Tenant.Dynamic configuration.Tenant.Derived = configuration.Tenant.Dynamic
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic")) resolution.RecordNormalization(Keys("tenant", "derived"), "derive from dynamic input", Keys("tenant", "dynamic"))
return nil return nil
}, },
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" }, Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },

View File

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

View File

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

View File

@@ -50,6 +50,16 @@ func (e *LoadError) Sources() []SourceInfo {
return cloneSourceInfos(e.sources) return cloneSourceInfos(e.sources)
} }
// Source returns a detached record from the source evidence retained before
// loading failed. IDs are local to the failed load. Empty or unknown IDs return
// false.
func (e *LoadError) Source(id string) (SourceInfo, bool) {
if e == nil {
return SourceInfo{}, false
}
return lookupSourceInfo(e.sources, id)
}
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal // WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes. // control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
@@ -88,7 +98,9 @@ func (a *diagnosticAccumulator) loadError() *LoadError {
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic { func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics) canonical := cloneDiagnostics(diagnostics)
for i := range canonical { for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation) if canonical[i].Code != CodeComposeConflict {
slices.SortFunc(canonical[i].Related, compareLocation)
}
} }
type orderedDiagnostic struct { type orderedDiagnostic struct {
diagnostic Diagnostic diagnostic Diagnostic

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"io" "io"
"slices"
"strings" "strings"
"testing" "testing"
) )
@@ -110,6 +111,44 @@ func TestCanonicalDiagnosticsSortEscapedPaths(t *testing.T) {
} }
} }
func TestCanonicalDiagnosticsPreservesCompositionClaimantOrder(t *testing.T) {
composition := Diagnostic{
Code: CodeComposeConflict,
Severity: SeverityError,
Path: Keys("value"),
Primary: &Location{SourceID: "source-4"},
Related: []Location{
{SourceID: "source-5"},
{SourceID: "source-3"},
},
}
other := Diagnostic{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("other"),
Related: []Location{
{SourceID: "source-5"},
{SourceID: "source-3"},
},
}
canonical := canonicalDiagnostics([]Diagnostic{composition, other})
var compositionResult, genericResult Diagnostic
for _, diagnostic := range canonical {
if diagnostic.Code == CodeComposeConflict {
compositionResult = diagnostic
} else {
genericResult = diagnostic
}
}
if got := []string{compositionResult.Related[0].SourceID, compositionResult.Related[1].SourceID}; !slices.Equal(got, []string{"source-5", "source-3"}) {
t.Fatalf("composition related locations = %v, want claimant order", got)
}
if got := []string{genericResult.Related[0].SourceID, genericResult.Related[1].SourceID}; !slices.Equal(got, []string{"source-3", "source-5"}) {
t.Fatalf("generic related locations = %v, want sorted order", got)
}
}
func TestWriteDiagnosticsEscapesControls(t *testing.T) { func TestWriteDiagnosticsEscapesControls(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -5,8 +5,8 @@ import (
"errors" "errors"
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" yamladapter "go.maximumdirect.net/configkit/internal/yaml"
) )
var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable") var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable")
@@ -34,11 +34,11 @@ func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
deltas := compareConfigurationValues(left.final, right.final) deltas := compareConfigurationValues(left.final, right.final)
regions := make([]diffRegion, 0, len(deltas)) regions := make([]diffRegion, 0, len(deltas))
for _, delta := range deltas { for _, delta := range deltas {
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.immutableLookup) leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.documentLookup())
if !leftPresent { if !leftPresent {
leftNode = nil leftNode = nil
} }
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup) rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.documentLookup())
if !rightPresent { if !rightPresent {
rightNode = nil rightNode = nil
} }

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