201 Commits

Author SHA1 Message Date
bc6b977585 Split the sequence implementation into smaller stages 2026-10-23 13:40:29 +00:00
d06aae5461 Plan sequence identity and combination implementation 2026-10-23 12:56:23 +00:00
4f941f8ba4 Clarify sequence policy interactions and rename the roadmap 2026-10-23 12:20:18 +00:00
9a49833339 Remove the completed operator convention audit 2026-10-23 12:17:29 +00:00
749cf6868c Settle the sequence identity registration API 2026-10-23 12:09:44 +00:00
a5f0b92ee9 Add dynamic identity constraints to the sequence roadmap 2026-10-23 01:56:00 +00:00
6ec23b2de1 Extend collection inheritance architecture to identified sequences 2026-10-23 01:42:32 +00:00
3298eb916c Settle how sequence inheritance references compose 2026-10-23 01:40:37 +00:00
40430e787c Add identified record inheritance to the sequence roadmap 2026-10-23 01:31:22 +00:00
f434fa159f Align architecture policy with collection and CLI decisions 2026-10-23 01:12:11 +00:00
8ddf73ede3 Accept collection policy and CLI lifecycle decisions 2026-10-23 01:09:24 +00:00
844a15a3cb Consolidate the sequence roadmap around the settled design 2026-10-23 00:11:50 +00:00
58f4c39d6f Settle sequence identity declarations without values 2026-10-23 00:04:55 +00:00
8a54cecd1d Settle nested sequence policy ownership and record review findings 2026-10-23 00:02:26 +00:00
0e430e8077 Allow lower-tier identity during strict composition 2026-10-22 23:53:48 +00:00
26caa6024e Settle persistent sequence operation policy 2026-10-22 23:47:50 +00:00
8691a93ebf Settle CLI ordering before application resolution 2026-10-22 23:41:06 +00:00
c563f80934 Record remaining sequence lifecycle decisions 2026-10-22 15:45:21 +00:00
27797d2392 Settle sequence inheritance and lifecycle coverage 2026-10-22 15:16:42 +00:00
6a4e57e5d3 Settle sequence validation and absent value behavior 2026-10-22 14:47:00 +00:00
29f94d89e1 Settle sequence mode names and identity-based defaults 2026-10-22 14:39:40 +00:00
12f6045da0 Settle sequence combination scope and ordering 2026-10-22 14:08:55 +00:00
6eb80b06e3 Settle sequence identity definitions and policy lifetime 2026-10-22 13:28:18 +00:00
86feb6d755 Settle collection declaration syntax in the sequence roadmap 2026-10-21 14:00:02 +00:00
8bf49f6ea6 Include overlay author controls in the sequence proposal 2026-10-20 14:08:57 +00:00
f3d0fe6411 Record final identity guarantees for application registrations 2026-10-20 14:03:26 +00:00
cba3a0efb0 Record whole-record sequence replacement and application identity constraints 2026-10-20 13:57:19 +00:00
b58cd75c98 Draft sequence identity and layered append proposal 2026-10-20 13:48:54 +00:00
c530a989df Audit operator conventions against Ansible 2026-10-20 13:28:21 +00:00
2baf14d9a5 Retire completed import integration remediation 2026-10-20 04:11:54 +00:00
6903c9e51a Clarify empty path import inspection evidence 2026-10-20 01:06:15 +00:00
31e10af9cb Clarify scheduled provenance and history ordering 2026-10-20 01:05:34 +00:00
0d841d9d73 Resolve contained symlinks in import directory prefixes 2026-10-20 01:04:31 +00:00
4bcefbc9d4 Record import integration audit findings 2026-10-20 00:38:04 +00:00
91db99b967 Retire completed import discovery plans 2026-10-20 00:32:11 +00:00
5747ec49c6 Count import depth only for selected sources 2026-10-20 00:30:35 +00:00
d3a714a1cb Validate complete import depth numbers 2026-10-20 00:27:55 +00:00
7f8ad81065 Track active symlink expansions precisely 2026-10-20 00:26:30 +00:00
75da54150f Resolve native imports beneath nested roots 2026-10-20 00:23:55 +00:00
38fb315415 Check item version ownership at effective paths 2026-10-20 00:22:43 +00:00
1c79ec3aa1 Preserve native import alias identity 2026-10-20 00:20:38 +00:00
01e1b2fc2d Record import discovery review findings 2026-10-20 00:03:23 +00:00
15943e233c Record completed import roadmap 2026-10-19 21:23:54 +00:00
f6a6b0184b Finalize import discovery documentation and audits 2026-10-19 21:23:47 +00:00
898035d83a Record prompt catalog example completion 2026-10-19 21:15:53 +00:00
de3666aed3 Expand prompt catalog discovery example 2026-10-19 21:15:47 +00:00
c00811da2e Record lifecycle audit completion 2026-10-19 21:13:29 +00:00
55abcf8340 Audit scheduled import and inheritance lifecycles 2026-10-19 21:13:21 +00:00
9d7c79cf0b Record public import API completion 2026-10-19 21:06:39 +00:00
24131617c7 Expose import defaults and selection evidence 2026-10-19 21:06:31 +00:00
05b67cbfcf Record empty import evidence completion 2026-10-19 20:57:53 +00:00
e433988962 Retain empty optional import selection evidence 2026-10-19 20:57:41 +00:00
05a28d1178 Record final import wiring completion 2026-10-19 20:51:01 +00:00
1e5bffc0e6 Wire final import declarations through loading 2026-10-19 20:50:47 +00:00
f5a689b578 Record glob discovery completion 2026-10-19 20:38:48 +00:00
e4abff4164 Filter and account final glob candidates 2026-10-19 20:38:42 +00:00
1a8f1cc3a5 Record directory link discovery completion 2026-10-19 20:29:36 +00:00
9b99463187 Follow links safely during directory discovery 2026-10-19 20:29:20 +00:00
f69fa24b40 Record recursive directory search completion 2026-10-19 20:18:44 +00:00
77d97296ea Add recursive directory search with depth limits 2026-10-19 20:18:39 +00:00
032679bf34 Record shallow directory selection completion 2026-10-19 20:13:39 +00:00
55a59de6db Select shallow directory import sources 2026-10-19 20:13:33 +00:00
a0d13962bd Record named import selection completion 2026-10-19 20:08:05 +00:00
5f33e1e90c Select named imports before source admission 2026-10-19 20:07:54 +00:00
b750b0fec6 Record private import parser completion 2026-10-19 20:02:12 +00:00
b498e5bee5 Define private import defaults and declaration parsing 2026-10-19 20:02:05 +00:00
ba577429e6 Record underlay group scheduling completion 2026-10-19 19:53:08 +00:00
014b99504b Schedule underlay groups as dependency forests 2026-10-19 19:53:01 +00:00
263dd2f1bd Record item scope scheduling completion 2026-10-19 19:39:30 +00:00
af43d6f438 Schedule item-local import scopes 2026-10-19 19:39:19 +00:00
a4cbd6390e Record ordinary import scheduling completion 2026-10-19 19:29:52 +00:00
5ed0f55970 Schedule ordinary import contributions by dependency 2026-10-19 19:29:35 +00:00
91ab37a39d Record import graph completion 2026-10-19 17:34:45 +00:00
fbe9ae6d8b Retain import inclusion graph during acquisition 2026-10-19 17:34:21 +00:00
1aa121df71 Record scheduled inheritance completion 2026-10-19 17:25:32 +00:00
6e40c166d6 Schedule sibling inheritance by dependency readiness 2026-10-19 17:25:27 +00:00
a56d4691ff Record inheritance graph groundwork 2026-10-19 15:18:37 +00:00
2718fbb8fe Separate inheritance graph analysis and application 2026-10-19 15:18:25 +00:00
2d52421919 Record deterministic scheduler completion 2026-10-19 15:11:42 +00:00
167fcd475f Add deterministic dependency scheduler 2026-10-19 15:11:28 +00:00
aa93aeda3f Plan import discovery and shared dependency ordering 2026-10-19 14:33:01 +00:00
94381a67fd Record accepted import architecture decisions 2026-10-19 14:24:19 +00:00
c26b34147b Clarify import candidate identity, accounting, and inspection 2026-10-19 14:15:01 +00:00
c9f9c98fbe Settle import validation and require recursion for depth 2026-10-19 14:05:18 +00:00
f6bf4454b0 Settle import selector lists and exclusions 2026-10-19 13:43:56 +00:00
a7ca2c59a2 Settle import defaults, discovery boundaries, and inspection decisions 2026-10-19 01:54:01 +00:00
10c14aa794 Add architecture principles for simplicity and permissive defaults 2026-10-18 21:13:28 +00:00
e9774b252b Adopt shared dependency-ready ordering for imports and inheritance 2026-10-18 21:09:05 +00:00
19fe621df1 Apply permissive defaults and remove import transition support 2026-10-18 16:37:07 +00:00
82b289f4fe Clarify import roadmap decisions using Ansible conventions 2026-10-18 16:28:38 +00:00
137769f3f1 Document the ConfigKit v0.8.0 release 2026-10-17 04:58:01 +00:00
ef28eabaf8 Retire completed sequence composition plans 2026-10-17 04:56:51 +00:00
008d2ba9c8 Clarify when sequence identities are validated 2026-10-17 04:55:32 +00:00
8e3a0892d4 Project item conflict diagnostics through enclosing scopes 2026-10-17 04:54:01 +00:00
023e428b9c Index sequence assembly and history lookups 2026-10-17 04:52:20 +00:00
527a38daf1 Preserve defaults-only input absence 2026-10-17 04:49:33 +00:00
30bcd8eeb6 Protect the main version inside item scopes 2026-10-17 04:48:25 +00:00
884215c403 Honor parsed sequence indexes in version protections 2026-10-17 04:46:53 +00:00
8a89806e44 Explain descendants removed by sequence replacement 2026-10-17 04:45:49 +00:00
26720b98f0 Preserve item helper history in source order 2026-10-17 04:44:29 +00:00
4633a1b8a2 Project consumed version evidence through sequence appends 2026-10-17 04:42:23 +00:00
87986a12d2 Record sequence composition review findings 2026-10-17 01:50:02 +00:00
0858af1029 Report malformed sequence items at assigned paths 2026-10-16 19:41:48 +00:00
acddce8e2f Document sequence composition for consumers 2026-10-16 19:37:59 +00:00
77c5d9482a Close cross-cutting sequence regression gaps 2026-10-16 19:32:31 +00:00
adef16ccdd Expose sequence placement controls in the public API 2026-10-16 19:23:43 +00:00
c19825620c Project version protections through item placement 2026-10-16 19:11:08 +00:00
190431740a Apply sequence policy across loading tiers 2026-10-16 18:59:52 +00:00
25cf6d32b6 Assemble imported sequence-item scopes 2026-10-16 16:20:14 +00:00
1a04bb3835 Add private sequence-item placement 2026-10-16 15:55:29 +00:00
1cf3b0d78f Retain ordered acquisition scopes 2026-10-16 15:48:36 +00:00
66dced0823 Project history and sensitivity paths 2026-10-16 15:44:44 +00:00
b1b5163cc3 Preserve append history across tiers 2026-10-16 15:36:46 +00:00
bb9bde799a Project sequence origins onto appended indexes 2026-10-16 15:29:27 +00:00
dc7a70811a Compose registered sequences by identity 2026-10-16 15:25:06 +00:00
ff212885c4 Prepare sequence registration preflight 2026-10-16 15:19:32 +00:00
90c2d7e409 Clarify unresolved source destination evidence in implementation plan 2026-10-16 14:49:23 +00:00
60e6e2697f Plan sequence placement and composition 2026-10-16 14:36:46 +00:00
20511773fc Settle sequence placement and composition roadmap decisions 2026-10-16 14:22:59 +00:00
31fb6a9bd1 Extend sequence roadmap with item placement and open decisions 2026-10-16 14:18:36 +00:00
103e10639a Plan identity-based sequence composition 2026-10-16 14:05:43 +00:00
97c5a0390a Retire deferred scope and preserve future performance experiments 2026-10-16 13:53:34 +00:00
8b547765f0 Record selective source version requirements as future work 2026-10-16 13:42:37 +00:00
5215cf5c4f Catalog identified record sequences as future work 2026-10-16 12:07:49 +00:00
e66c37161c Retire completed roadmaps before the next release 2026-10-16 11:36:21 +00:00
e4270c9b14 Allow absent version headers below structural controls 2026-10-15 22:22:56 +00:00
e69ad0f32d Keep complete paths in missing version evidence 2026-10-15 22:20:20 +00:00
0a2246bdbe Check import opt-in before processing source headers 2026-10-15 22:18:57 +00:00
e5a6df7af7 Preserve header sensitivity across colliding path shapes 2026-10-15 22:17:49 +00:00
25cb3c47e7 Record source version review findings 2026-10-15 22:11:44 +00:00
020d72084d Document shared source version checks 2026-10-15 16:23:39 +00:00
358b28c421 Add source version adoption example 2026-10-15 16:19:24 +00:00
b0550af6a3 Audit source version lifecycle agreement 2026-10-15 16:17:21 +00:00
43c994a32a Publish detached sanitized source evidence 2026-10-15 16:12:21 +00:00
1e47ee21cd Propagate source evidence sensitivity through history 2026-10-15 16:00:01 +00:00
fed591183b Classify source version evidence at original paths 2026-10-15 15:55:16 +00:00
1f95cf905a Expose source version modes in load options 2026-10-15 15:48:59 +00:00
15f5b591b9 Check source versions in selected overlays 2026-10-15 15:44:04 +00:00
184f29c7af Check source versions in direct underlays 2026-10-15 15:40:11 +00:00
813d6a38c9 Validate source versions in recursive imports 2026-10-15 15:33:40 +00:00
23ae700194 Integrate source version checks for main inputs 2026-10-15 15:24:53 +00:00
49218a836b Add source-local version extraction 2026-10-15 15:15:05 +00:00
2fcf892324 Separate version interpretation from reporting 2026-10-15 15:04:57 +00:00
8645cab79a Record shared source version policy 2026-10-15 15:02:50 +00:00
70c95bf660 Plan shared source version checks 2026-10-15 14:24:51 +00:00
d9f08ff719 Settle source version edge-case decisions 2026-10-15 14:05:25 +00:00
2c91e41d10 Record source version decisions and remaining edge cases 2026-10-14 20:30:58 +00:00
e3860036f8 Plan shared source version checks 2026-10-14 19:03:25 +00:00
b3fc6a5950 Restore the original input destination decision 2026-10-14 18:05:48 +00:00
a858a066e4 Record relative import destination review findings 2026-10-14 17:52:14 +00:00
10edd95d4d Complete relative import destination documentation audit 2026-10-14 17:45:31 +00:00
08e03f6a38 Demonstrate entry-declared import destinations 2026-10-14 17:41:35 +00:00
a20ba0af27 Verify relative imports through the application lifecycle 2026-10-14 17:39:28 +00:00
6458dbd3aa Preserve import provenance and resource contexts 2026-10-14 17:34:52 +00:00
43e55dfa85 Verify accumulated import resource accounting 2026-10-14 17:29:54 +00:00
430985c629 Verify relative import composition and version ownership 2026-10-14 17:27:49 +00:00
4635ef08d5 Add relative destinations to import declarations 2026-10-14 17:24:57 +00:00
49c0c6d908 Pass effective destinations through import traversal 2026-10-14 17:21:07 +00:00
a8beb3e620 Record relative import destination decision and baseline 2026-10-14 17:17:50 +00:00
934c1022e0 Record PromptKit feature request decisions 2026-10-14 17:09:23 +00:00
7248a8bd95 Plan relative import destination implementation 2026-10-14 16:53:50 +00:00
b2db3233b2 Settle relative import destination syntax 2026-10-14 16:44:11 +00:00
edb6d05ec1 Plan relative import destinations 2026-10-14 16:39:10 +00:00
315f464dfc Correct mounted mapping diagnostic paths 2026-10-14 15:49:51 +00:00
a029a2a754 Record input destination review findings 2026-10-14 14:12:18 +00:00
945e111c4e Complete input destination implementation audit 2026-10-14 14:06:38 +00:00
2d8df22dd1 Add mounted collection consumer examples 2026-10-14 13:59:37 +00:00
53601e68ed Expose input destinations through the public facade 2026-10-14 13:56:22 +00:00
751787ec24 Verify mounted inheritance and sensitivity integration 2026-10-14 13:50:08 +00:00
f50122d9f8 Verify mounted provenance and resource contexts 2026-10-14 13:38:56 +00:00
c71faf2065 Count destination wrappers in shared input limits 2026-10-14 13:31:32 +00:00
2f5d57ce1d Verify mounted diagnostic paths and locations 2026-10-14 13:25:55 +00:00
a1c557603f Verify mounted composition and version ownership 2026-10-14 13:24:06 +00:00
a2fab66aba Carry destinations through imported configuration 2026-10-14 13:19:31 +00:00
2f1f7f4b8a Integrate private destinations for layered inputs 2026-10-14 13:14:06 +00:00
667a536ae7 Add private input destination placement helpers 2026-10-14 13:09:06 +00:00
ea55ab43ce Record input destination decision and baseline 2026-10-14 13:05:09 +00:00
d3d468a8b4 Plan configuration input destination implementation 2026-10-14 12:48:08 +00:00
6353598bd6 Settle input destination roadmap decisions 2026-10-14 12:40:06 +00:00
3c2d7facb3 Plan configuration input destinations 2026-10-14 03:27:34 +00:00
6fffde4af8 Prepare ConfigKit v0.7.1 release 2026-10-11 01:14:23 +00:00
9e04a397c4 Upgrade ReleaseKit to v0.4.1 2026-10-11 01:10:26 +00:00
c54a9bf51e Upgrade ReleaseKit to v0.4.0 2026-10-10 23:51:44 +00:00
8f3396d6f0 Retire completed diff plans for release preparation 2026-10-09 14:23:20 +00:00
1c2610c2c0 Prepare release notes for v0.7.0 2026-10-09 14:21:37 +00:00
92025d65cb Correct diff masking for historical descendants 2026-10-09 13:22:40 +00:00
a4b5a6840a Record effective diff review findings 2026-10-09 12:57:40 +00:00
2ebdfb539b Document and audit effective configuration diffs 2026-10-09 12:42:17 +00:00
a37e6895e1 Add effective diff consumer examples 2026-10-09 12:37:18 +00:00
8ae542b3a3 Expose effective diffs through the root facade 2026-10-09 12:34:11 +00:00
174efb65f6 Assemble the safe engine diff operation 2026-10-09 12:30:20 +00:00
756149beaa Track hidden changes behind redaction 2026-10-09 12:24:01 +00:00
e5dddef204 Mask sensitive structural replacements 2026-10-09 12:22:36 +00:00
ade5d6a273 Prepare paired safe snapshots for diffs 2026-10-09 12:20:15 +00:00
c20ea4b916 Index combined diff sensitivity 2026-10-09 12:16:13 +00:00
2e17e365a1 Prepare canonical configuration diff regions 2026-10-09 12:14:47 +00:00
fcf914a2ab Record effective diff baseline evidence 2026-10-09 12:12:12 +00:00
3697a51124 Accept the effective configuration diff decision 2026-10-09 12:10:56 +00:00
9194c5b52a Plan effective configuration diff implementation 2026-10-09 05:09:25 +00:00
e82b1c9054 Finalize the effective configuration diff roadmap 2026-10-09 05:02:49 +00:00
6f59cdd237 Plan structured effective configuration diffs 2026-10-09 04:55:29 +00:00
153 changed files with 23577 additions and 845 deletions

View File

@@ -9,7 +9,7 @@ ConfigKit's simple `Load` accepts one strict YAML source (or explicit
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
source groups with an independently selected main input. Sources can be host
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
Enabled path and glob imports compose strictly within their group; precedence
Enabled explicit imports compose strictly within their group; precedence
between groups combines mappings recursively. Both entry points return a complete,
validated configuration with detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
@@ -20,7 +20,27 @@ secrets-directory bootstrap is available as a separate startup operation. Explic
CLI override records apply above overlays. 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.
same load, retaining explicit presence and source/derivation history. The root
`Diff` API compares frozen effective values from two successful same-type loads;
the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
Sources can also be explicitly placed as sequence items, and definitions can
register selected sequences to append records with distinct string IDs during
strict composition. Higher-priority sequences still replace lower-priority
sequences as whole values. See the [sequence contract](docs/config.md#strict-document-composition),
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
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).
```go
type Config struct {
@@ -58,18 +78,26 @@ children; other values replace lower-priority values. The main document and its
imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay and explicit CLI overrides sit
above it. The [configuration contract](docs/config.md#strict-document-composition)
owns the detailed combination rules and input-specific constraints.
owns the detailed combination rules and input-specific constraints. At
definition-registered paths only, strict composition can append records with
distinct explicit IDs; ordinary and unregistered sequences keep their conflict
or whole-value replacement behavior.
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
these priorities and how genuine new needs can justify extending the shared
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
consideration in future releases.
The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained simple
native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes.
See the [development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.
The [configuration contract](docs/config.md) describes implemented behavior,
including [input destinations](docs/config.md#input-destinations). The
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
layered, mounted-collection, and identified-prompt examples alongside the
migration shapes. Input placement decisions are recorded in
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
See the [development guide](docs/development.md) for repository orientation and
the [release procedure](docs/release.md) for maintainer-only candidate and
publication steps.
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

@@ -43,10 +43,99 @@ func toEngineLocations(values []Location) []engine.Location {
return result
}
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
return SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
}
}
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
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 {
return nil
}
result := make([]ImportSelection, len(values))
for index, value := range values {
result[index] = ImportSelection{
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
}
}
return result
}
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
if values == nil {
return nil
}
result := make([]engine.ImportSelectionForFacade, len(values))
for index, value := range values {
result[index] = engine.ImportSelectionForFacade{
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
Selector: string(value.Selector), Outcome: string(value.Outcome),
}
}
return result
}
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
return engine.ImportDefaults{
Required: cloneImportDefaultBool(value.Required),
Follow: cloneImportDefaultBool(value.Follow),
Hidden: cloneImportDefaultBool(value.Hidden),
Recursive: cloneImportDefaultBool(value.Recursive),
Pattern: cloneImportDefaultStrings(value.Pattern),
}
}
func cloneImportDefaultBool(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneImportDefaultStrings(values []string) []string {
if values == nil {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil {
return nil
}
return &SourceVersionCheck{
Path: fromEnginePath(value.Path),
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
}
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
if value == nil {
return nil
}
return &engine.SourceVersionCheck{
Path: toEnginePath(value.Path),
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
}
func fromEngineOrigin(value engine.Origin) Origin {
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}

53
diff.go Normal file
View File

@@ -0,0 +1,53 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// Difference describes one canonical changed region between two successful
// loads. Left is the earlier value and Right is the later value. Each snapshot
// is detached; Present distinguishes absence from an explicit null, and
// Redacted reports whether combined sensitivity affected that side. Mappings
// are compared recursively, while sequences are reported as atomic values.
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
// a value concealed by combined sensitivity changed, without revealing which
// concealed member changed.
type Difference struct {
Path Path
Left ValueSnapshot
Right ValueSnapshot
HiddenChanged bool
}
// Diff compares the frozen effective values of two successful loads of the
// same configuration type. It does not reread sources, environment variables,
// or rerun defaults or resolvers. Equal values produce no records even when
// their sources, defaults, or resolution histories differ. Credential contents
// are not compared: typed secret references compare by reference name only.
//
// Both results must be non-nil successful loads with inspection state; nil or
// zero results return an error and no records. Load errors are returned by
// Load, so callers must handle those before calling Diff. Snapshots are safe
// for presentation but are not a complete reloadable configuration.
func Diff[T any](left, right *Result[T]) ([]Difference, error) {
var leftInspection, rightInspection *engine.Inspection
if left != nil {
leftInspection = left.inspection
}
if right != nil {
rightInspection = right.inspection
}
values, err := engine.Diff(leftInspection, rightInspection)
if err != nil {
return nil, err
}
differences := make([]Difference, len(values))
for index, value := range values {
differences[index] = Difference{
Path: fromEnginePath(value.Path),
Left: fromEngineValueSnapshot(value.Left),
Right: fromEngineValueSnapshot(value.Right),
HiddenChanged: value.HiddenChanged,
}
}
return differences, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,13 +67,18 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
semantic validator, explicit path and glob imports, one selected overlay, and
filesystem context. It also accepts explicit CLI override records, static secret
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
and filesystem context as invalid combinations but accepts overrides. Input
containing a reserved `configkit` mapping key is rejected while imports are
disabled.
`Options` accepts the application definition, ordinary-source `Destination` and
`PlacementMode`, explicit imports, one selected overlay, filesystem
context, explicit CLI overrides, and shared source-version controls. The definition
may register strict sequence composition paths through
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. `NoSource` rejects
imports, active overlays, and filesystem context as invalid combinations but
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
while imports are disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
@@ -149,9 +154,60 @@ have index zero. Explicit empty groups and `NoSource` consume no source ID.
Byte, source, node, and glob-entry budgets are shared across the load, while
the existing per-source and import-depth bounds still apply.
When a source mapping is explicitly placed as an item, its effective destination
is the sequence container. After assembly assigns an index, nested source
destinations are projected through that item. Physical source locations and
resource contexts continue to identify the declaring source.
#### Input destinations
`Input.Destination` places the source's application mapping beneath a path
relative to the configuration root. Its zero or empty value means root, so
existing inputs keep their behavior. Main inputs and underlay inputs use the
same destination model. Destination segments are literal mapping keys: dots,
slashes, and numeric-looking text are not split or interpreted as sequence
indexes. Explicit index segments and paths that introduce the reserved
`configkit` or mapping-valued `extends` structure are invalid input options and
produce a structured `LoadError` before source acquisition.
Source-root `configkit.imports` directives are processed before placement. An
import without an explicit destination inherits the effective destination of
its importing document, including through nested imports. An import may instead
declare a relative destination as a sequence of literal mapping keys; its keys
append to the importing document's effective destination. Omission and `[]`
add no keys. Nested imports continue relative to their immediate importing
document, each declaration is independent of its siblings, and all matches of
one glob receive the same resulting destination. This logical placement does
not change how the physical import path resolves from its declaring file.
A valid destination on `NoSource()` contributes nothing; an actual empty
mapping materializes its destination containers. Once placed, contributions
follow the same strict composition within a tier and recursive precedence
behavior described above. A selected overlay and CLI overrides remain rooted at
the application root and are not moved beneath the main input's destination.
`SourceInfo.Destination` reports each acquired source's effective mapping or
sequence-container path, including imports. `PlacementMode` reports how that
source was placed; mapping is the zero value, and CLI/overlay sources report
mapping unless item placement was applied. `DestinationUnresolved` is true only
when an enclosing item index needed to name the source's destination has not yet
been assigned. In that case `Destination` reports the nearest resolved enclosing
collection container and omits the unresolved local suffix. A source's own item
placement still has an exact destination at its sequence container. Completed
loads project nested source destinations through their owning items. Callback and
failed-load snapshots preserve the evidence available at that point; a fallback
snapshot remains unresolved, while a later exact snapshot is false. Returned
source records and paths are detached. Placement does not change source IDs,
acquisition identity, physical source locations, or passive resource contexts.
Introduced ancestor mappings use the contributing document's root location as a
source fallback; authored content retains its original source coordinates.
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
and explicit resource access without a second configuration pass.
The accepted rationale for input placement and its relative-import extension
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
[ADR 0012](adr/0012-relative-import-destinations.md).
### Sibling inheritance
@@ -164,6 +220,13 @@ value of null cancels a relationship without looking up a parent. Other value
types, missing or non-mapping parents, and cycles fail the load with value-free
diagnostics.
Within each sibling scope, direct mapping definitions resolve by dependency
readiness. A parent is resolved before a child; among currently eligible
definitions, the lexically smallest canonical semantic pointer is selected,
including newly eligible definitions as soon as their parent completes. This
ordering governs inheritance operations and their history, controls, and
transfers. It does not reorder the mappings in the resulting configuration.
`extends` is reserved structural input across ordinary and layered loads, every
source kind, imports, selected overlays, and explicit CLI overrides. It is
consumed before typed decoding and need not appear in the application's Go
@@ -269,6 +332,67 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures.
#### Shared source-version checks
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
one `Definition.Version` policy applies across source documents. The zero value
`SourceVersionModeDefault` preserves the existing main-only check at its
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
checks each participating document that declares a version and permits absent
non-main declarations. `SourceVersionModeRequired` rejects a participating
document without a declaration. Enabled modes require a usable `VersionRule`
and checker; an unknown mode or unusable rule is an options error before source
access or callbacks.
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
document's own coordinates, before placement and composition. The main document,
direct underlay documents, imported documents (including transitive and glob
matches), and the selected overlay participate. `NoSource`, absent optional
imports, empty underlay groups, defaults, resolution output, inheritance, and
synthetic CLI overrides are not documents to check. An absent declaration in an
actual main document still reaches the checker in if-present mode; required mode
rejects it first. With `NoSource`, the existing single absent-main checker
behavior remains in both enabled modes. If-present omissions in non-main
documents skip the checker. Explicit null is present and reaches the checker;
unsupported selected values produce `version.type`. No equality with the main
declaration or non-null rule is imposed. Acceptance establishes only that the
declaration is accepted by the consumer's checker; it does not establish that
each fragment conforms to a complete application schema. Final assembled
decoding and application validation still run once.
A header that is absent below a structural control follows the same omission
policy. Actual header/control overlap is rejected, and ordinary structural
validation and main-version ownership protection continue independently.
Accepted non-main declarations are consumed from their owned payload before
composition; their authored ancestors remain, even if empty. The main
declaration remains in the application. Ordinary release metadata should use a
different path. Enabled main protection uses the main input's effective path:
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
the remaining payload against imports, underlays, inheritance, and CLI
overrides. Thus a mounted main looks up a local header while ownership checks
protect its destination-prefixed application path. Main checker diagnostics and
declaration locations use source-local coordinates; ordinary payload
diagnostics continue to use effective paths. Default-mode diagnostics are
unchanged.
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
occur. Otherwise it records a source-local `Path`, the observed `Version` state
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
available `Location`, and whether text was redacted. A non-nil record with
`Present == false` records observed absence; it does not say the checker ran or
accepted a declaration. Absent evidence still identifies the full requested
header path, while its location identifies the nearest available source node.
During callbacks and in failed-load source records,
text is conservatively withheld because final classification may not be
available. Successful final source records reflect completed sensitivity
classification at the declaration's mounted path and ancestors, including
historical classification. Redaction empties `Text` while retaining presence
and null state. Returned copies are detached; evidence does not reopen inputs.
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
decision and the [development guide](development.md#baseline-validation) for
the completed implementation and remediation records in Git history.
### Layered metadata
`Result.Metadata` describes every final canonical path. Queries retain explicit
@@ -299,15 +423,21 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived
value for absent input remains non-explicit.
Appending registered records during strict composition assigns each contribution
its assembled sequence index in history. A later precedence tier that supplies a
sequence records replacement at the sequence path; history does not imply
identity-based merging across tiers.
A mapping assembled from the selected file and imports has one composite base origin
whose locations retain source traversal order. A mapping also visited by an overlay
has an overlay-layer composite origin with the lower contributing locations followed
by the overlay location; an empty mapping is still an explicit container even though
it contributes no children. Resolution origins are synthetic: they have layer
whose locations follow [scoped assembly order](#explicit-imports), not source-record
acquisition order. A mapping also visited by an overlay has an overlay-layer
composite origin with the lower contributing locations followed by the overlay
location; an empty mapping is still an explicit container even though it contributes
no children. Resolution origins are synthetic: they have layer
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
operation. Reasons and dependency paths retain first occurrence order with duplicates
removed. Source records remain ordered as selected source, depth-first imports, the
selected overlay, then synthetic CLI records in supplied order.
removed. Source records and IDs retain [acquisition order](#explicit-imports),
including the underlay ordering described in [layered loading](#layered-loading).
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
lists concrete transfers from contributor paths through destinations, with
@@ -370,9 +500,11 @@ historical path. Its effective value and each chronological before/after value a
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
parser values. `Present=false` with empty YAML means absence or deletion; explicit
null is present and renders as `null`. The history starts with the canonical typed
application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected overlay, supplied CLI records, and audited net
resolution operations. Structural inheritance events appear after source replay
application-default or Go-zero baseline, followed by relevant underlay and main-tier
contributions in precedence order, the selected overlay, supplied CLI records,
and audited net resolution operations. Within each strict tier, replay follows
[scoped assembly order](#explicit-imports), not source-record acquisition order.
Structural inheritance events appear after source replay
and before typed reconciliation, including consumed or canceled `extends` input
and equal-valued child replacements. Overlapping parent/child resolution records
are rendered
@@ -423,6 +555,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework.
## Effective configuration diffs
`configkit.Diff(left, right)` compares two completed successful loads of the same
configuration type. `left` is the earlier state and `right` is the later state.
Applications load both sides with the sources and options they choose; to inspect
an overlay's impact, load the same base once without the overlay and once with it
selected. The [consumer guide](consumers/README.md#effective-configuration-diffs)
links to maintained examples. The public API returns safe changed-region records;
applications own their presentation.
Comparison uses the frozen canonical effective inspection values, not mutable
`Result.Config`, source text, provenance, or history. It does not reopen sources,
reread environment variables, or rerun defaults or callbacks. Equal effective
values produce no records even when sources, explicit-input status, defaults, or
resolution histories differ; for example, explicit zero and defaulted zero are
equal. Mappings compare recursively, sequences compare atomically, and scalar,
kind, or null/container replacements report their affected region. Records use
the existing comparator's canonical pointer ordering. `Present=false` with empty
YAML means absence; explicit null is present and renders as `null`.
Before either side is returned, sensitivity from both loads is applied to both
snapshots, including historical and dependency-derived classification. A
classification on either side protects the corresponding values on both sides.
`Redacted` reports masking, while `HiddenChanged` reports an actual concealed
value or presence change; it is not implied merely by redaction. A structural
replacement that prevents safe correspondence masks the complete reported
region, and an absent side remains absent. Record membership establishes that a
change occurred even when safe snapshots render identically. Literal
marker-looking input is ordinary input; consumers use the record fields rather
than interpreting marker text.
Typed secret references contribute their configured environment names to the
frozen comparison, never resolved credential bytes. Credential rotation behind
an unchanged reference is therefore not detected. The records contain changed
regions only, not paired full-document context; independently calling
`WriteEffective` on each result does not create cross-protected context. Nil or
uninitialized results return an error with no records. A failed `Load` retains
its normal nil-result/`LoadError` behavior, which callers handle before invoking
`Diff`.
## Typed secret references
Credential-bearing fields use the opaque `Secret` type and store an environment
@@ -606,20 +778,55 @@ Application defaults do not participate in this operation.
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
sequences are present atomic values, so they also conflict when repeated; sequences
are never joined by index. Disjoint entries under shared mappings compose, making
Every non-mapping overlap is a conflict, even when values are equal, except for
sequence/sequence contributions at a registered sequence-composition path. Nulls
remain atomic conflicts. Unregistered sequences conflict when repeated and are
never joined by index. Disjoint entries under shared mappings compose, making
stable keyed mappings the natural representation for independently contributed
entries.
`Definition.SequenceCompositions` registers literal mapping paths and one literal
`IdentityField` for sequences that should compose independently authored records.
Registration paths follow input-destination key rules: parsed numeric tokens are
literal mapping keys, explicit index segments and reserved `configkit` or
mapping-valued `extends` segments are invalid, and the root registration is
harmless and unused. The identity field is one literal string key; neither the
field name nor identity strings are normalized.
At each registered path, every actual sequence contribution in a strict tier is
validated, including a singleton contribution: each record must be a mapping with
a present string identity field. Distinct exact identity strings append in source
and list order; duplicate identities conflict even if the records are otherwise
equal or disjoint. Empty identity strings are valid. Malformed records produce
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
sequence container and source locations identifying a duplicate without exposing
identity values. Missing paths and non-sequence values do not fail by themselves.
Identical registrations coalesce; conflicting registrations at one path and
invalid registration paths produce `options.invalid`. Unused and root registrations
are allowed and have no effect.
Registration changes strict composition only. Each underlay group and the main
tier has independent duplicate-identity state. Precedence assembly is unchanged:
a present higher-priority sequence replaces the lower sequence as one value,
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
and application resolution do not run the registered identity check. The accepted
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows item path and glob imports, helper-supplied identity, and main-tier
replacement of a lower-priority sequence.
When an enabled source-version mode is selected, declarations are checked in each
source's own coordinates before placement and composition. Accepted non-main
headers are consumed before strict composition and cannot supply a registered
record identity. Final typed decoding and application validation still run on the
completed configuration.
An empty mapping contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite
base origin. A conflict reports the first claim as its primary location and every
remaining claim as related locations, in deterministic source and declaration
traversal order. Traversal order never authorizes replacement. Composition clones
the resulting tree and its ordered claim records rather than mutating or aliasing an
input document.
base origin. A conflict reports the first claim in
[scoped assembly order](#explicit-imports) as its primary location and every
remaining claim as related locations in the same order. Assembly order never
authorizes replacement. Composition clones the resulting tree and its ordered
claim records rather than mutating or aliasing an input document.
## Selected overlays
@@ -710,10 +917,18 @@ zero.
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
fields are `import.invalid`. A `configkit` key below the root is invalid. With
imports disabled, any occurrence remains `import.disabled`.
application decoding. Each sequence item is a mapping with exactly one `path`,
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
`glob` accepts one path pattern string. The optional `required` boolean controls
whether an empty selection or a missing named path is an error. Its default is
false, so missing imports are absent unless `required: true` is specified. The
removed `optional` field is invalid. `destination` is an optional sequence of
strings. Every destination string is one literal mapping key and follows the
input-destination validation rules above; quote numeric-looking keys so YAML
reads them as strings. Null, scalar destinations, and non-string members are
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
key below the root is invalid. With imports disabled, any occurrence remains
`import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to
@@ -722,25 +937,107 @@ directive keys in root files and imported fragments, and rename any application
fields previously named `configkit` to avoid the newly reserved key.
Path declarations use slash-separated relative paths. Absolute paths and network
URLs are invalid. Each path resolves from its declaring file's canonical directory,
and files are traversed depth-first in declaration order: a file contributes its own
body before its imports. Order stabilizes source records and diagnostics but never
permits replacement. Required missing paths fail; `optional: true` suppresses only
target nonexistence, not invalid paths, containment failures, directories,
permissions, parse errors, or composition errors.
URLs are invalid. Each path resolves from its declaring file's canonical directory.
An empty `path: []` selects nothing; it succeeds even when `required: true` because
there are no named targets to be missing. A nonempty required path list verifies
every named target, including targets later excluded from selection.
Directory declarations search the listed literal directory roots. Missing roots
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
accepts one basename pattern or a list, matching filenames only. Omission selects
all filenames; an explicit empty list selects none. `exclude` accepts one basename
pattern or a list and removes matching final candidates. Patterns use Go's
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
`depth` is valid only when recursion is effective, with direct files at depth 1 and
depth 0 or omission meaning unlimited traversal subject to library budgets.
`follow` controls discovered symlink entries and `hidden` controls discovered
names beginning with a dot. Both default to true. Explicit roots retain their
normal access behavior.
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
Backslashes escape pattern characters rather than acting as directory separators.
ConfigKit validates the complete pattern before filesystem traversal, walks only
directories selected by preceding segments, and reads wildcard directories in
bounded batches. Every examined entry consumes the separate visit budget, including
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
each declaration before depth-first source traversal. A required zero-match glob is
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
walk failures, matched directories, invalid documents, escapes, and repeated targets
remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated.
classes, and backslash escapes are supported, while recursive `**` syntax is
invalid. Backslashes escape pattern characters rather than acting as directory
separators. ConfigKit validates the complete pattern before filesystem traversal,
walks only directories selected by preceding segments, and reads wildcard
directories in bounded batches. Every examined entry consumes the separate visit
budget, including nonmatches. Literal components are explicit path components;
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
final candidate basenames.
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` provide one
application-owned load-wide policy for enabled imports in the main source,
underlays, and transitive imports; they do not apply to selected overlays or
discover sources by themselves. Each applicable field resolves independently:
an explicit declaration value wins, then the application default, then the
library default (`required: false`, `follow: true`, `hidden: true`,
`recursive: false`, and unrestricted filenames). A declaration's `pattern`
replaces rather than extends the default pattern. Nil `Pattern` leaves directory
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
a field applicable to selectors that do not support it, and load calls snapshot
the supplied pointers and slice before source acquisition.
Acquisition visits declarations in authored order and each declaration's selected
paths in namespace-path lexical order, preserving source-record and version-callback
order. Ordinary mapping contributions in a main tier are assembled dependency-first:
ConfigKit selects the lexically smallest stable source key among the contributions
whose imports are complete. Independent branches may interleave, and the main body
may be assembled after its imports. Sequence-item contributions in that tier are
nodes in the same schedule; each selected item first assembles its own strict
mapping scope, with helpers before the bodies that import them. Those local scopes
do not use application collection policies and are not flattened into the outer
schedule. Item positions and diagnostic paths follow the resulting assembly order.
Each underlay group likewise acquires its inputs before dependency-ready assembly
in one strict scope. Independent groups remain separate precedence tiers; a higher
tier still replaces a lower tier's sequence values wholesale. Assembly order never
permits replacement.
Acquisition order and resolution order serve different purposes: acquisition
assigns source records and callback order, while the shared scheduler determines
composition order among currently dependency-ready contributions. Inheritance
uses the same eligibility rule within each destination-local sibling scope; it
does not reorder the authored output mapping. The private namespace and stable-key
construction is described in the [import implementation overview](internal/imports.md).
An empty selection is allowed by default. `required: true` makes an empty directory
or glob selection fail with `import.missing`; it also makes missing named paths
fail. Requiredness does not suppress invalid paths, containment failures,
directories selected as files, permissions, parse errors, or composition errors.
For inspection, `SourceInfo.ImportSelections` records one outcome for each
completed optional declaration whose entire selected set is empty. The record is
attached to its declaring source, keeps the declaration path local to that source,
identifies the declaration mapping (including its `SourceID`) and selector, and
uses outcome `none`. Records remain in declaration order and are available
through the existing result, load
error, and callback source snapshots. They do not enumerate candidates, explain
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
regardless of requiredness: it produces a `none` record when effectively optional,
and no record when effectively required. Snapshots detach the record paths and
slices.
Each declaration may include `placement: mapping` or
`placement: sequence-item`. Omission means `mapping` for that declaration,
including every match of a glob; placement is not inherited. Sequence-item
placement requires a nonempty effective destination and wraps the imported
mapping as one element of a sequence there. It does not split source sequences
or infer identity fields. Repeated sequences still conflict unless their path is
registered through `Definition.SequenceCompositions`; registration does not
change placement or precedence behavior. Sequence records retain source
locations through placement and history; indexes describe their assembled
positions while source coordinates continue to identify the declaring files.
Header/version policy and final application validation retain their existing
owners and timing; sequence registration is a strict-composition rule only.
An item import first opens a local mapping scope. Its body and ordinary helper
imports compose within that record. An omitted helper destination or
`destination: []` places the helper at the record root; other destinations are
relative to the immediate importing source. A helper may supply fields such as
the record's `id`. An explicit nested `sequence-item` import creates a sequence
inside the record, and its placement is independent of the enclosing item.
At the record root, an item import must name a nonempty nested destination.
Physical paths continue to resolve from the file that declares each import.
An actual empty mapping contributes an empty item, while an unmatched declaration
with default or explicit `required: false` contributes nothing.
For host files, the configuration root defaults to the selected file's canonical
directory. A host file may supply `FilesystemContext.Root` to widen that root but
@@ -759,8 +1056,13 @@ ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
distinct paths remain distinct sources. Native filesystems use slash-path operations
and their own symlink behavior; imports stay in their selected namespace and root.
distinct paths remain distinct sources. Native filesystems use slash-path
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
that capability to inspect and resolve discovered links; otherwise it follows
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
native filesystem to expose link inspection, and native filesystem behavior is
not a general sandbox guarantee. Imports stay in their selected namespace and
root.
An active-stack repeat is `import.cycle` and reports the complete cycle's import
sites. A previously visited canonical path is `import.repeated` and reports both

View File

@@ -17,7 +17,11 @@ sanitized shapes using only ConfigKit and the Go standard library:
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
@@ -51,6 +55,55 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts.
## Import defaults and selection inspection
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
load-wide policy to enabled main, underlay, and transitive imports. Each field
is resolved independently: a declaration's explicit value wins, then the
application default, then the library default. `Required`, `Follow`, `Hidden`,
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
directory searches: nil leaves filenames unrestricted, while a non-nil empty
slice selects no files. A declaration's own `pattern` replaces the application
default. The defaults do not enable imports or change selected-overlay behavior.
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
declaring source's `ImportSelections`. These records identify the source-local
declaration path, its authored location and selector, and the `none` outcome;
they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics.
## Shared source-version checks
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption,
main-path protection, default behavior, and evidence disclosure. Accepted
declarations do not replace final assembled decoding or application validation.
## Effective configuration diffs
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
with one application definition, handles each load error, calls `configkit.Diff`,
and presents the safe records through consumer-owned `FormatDifferences`.
[`CompareRolloutOverlay`](../../examples/consumers/diff.go) loads the same base
once without an overlay and once with the selected overlay, so omitted lower
priority values remain present. Its resolver records a derived endpoint URL after
overlay application, and its nested typed secret reference is classified before
the resulting records are presented.
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
use temporary files and disposable secret values. Presenters should use
`ValueSnapshot.Present` for absence, record membership for change, and
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
or add separately rendered full contexts that lack combined sensitivity. The
[configuration contract](../config.md#effective-configuration-diffs) owns shared
equality, granularity, and disclosure semantics.
## Named sibling inheritance
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
@@ -88,6 +141,80 @@ and may fail independently of the completed configuration load. The
[configuration contract](../config.md#source-resource-contexts) owns exact
lifetime and precedence rules.
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
counterpart: its entry opts into imports through `Load`, imports metadata at the
root, and places a prompt beneath `prompts` using YAML. The executable example
and behavior test are in
[`layered_test.go`](../../examples/consumers/layered_test.go).
## Input destinations and mounted collections
Main and underlay inputs use the same destination field. An import without a
`destination` inherits its declaring document's effective destination; an
explicit sequence of key strings appends a relative suffix. Nested imports
append to their immediate parent's effective destination, while filesystem
paths remain relative to the declaring file. Destinations are application
mapping paths, independent of filesystem context. The configuration contract
owns the complete behavior and validation rules.
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
mounts one mapping source beneath literal collection/prompt keys and loads a
second input without a destination to show that root placement remains the
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
to interpret.
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
embedded collection manifest mounted beneath a literal collection path. The
entry's YAML places nested metadata imports at the collection root and prompt
and profile peers beneath their own keys. A higher-priority input changes only
one prompt's ordinary `version` field. The main document alone supplies the
application schema version. A prompt's local `extends` relationship resolves
after all tiers, so `custom` inherits the final `brief` content and fields while
retaining its own profile selection.
The resolver follows each effective `content_file` origin to its declaring
source context. This also works for `custom`'s inherited resource. The
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
demonstrates the same lookup after loading; ConfigKit does not open or interpret
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
checks source destinations and authored coordinates, presence, inherited values,
relative resources, and strict peer conflicts. The
[configuration contract](../config.md#input-destinations) owns shared placement,
import, source-location, and ownership behavior.
## Identified prompt sequences
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
definition registration for the literal `prompts` path and `id` field. Its
load-wide filename defaults accept two patterns, an explicit directory
declaration overrides those patterns, and an exclusion omits one matching
candidate. A nested ordinary helper import supplies an item's ID. A separate
empty optional directory search is read from `SourceInfo.ImportSelections`.
The executable example prints both the scheduled prompt order and the single
empty-search outcome; the behavior test also follows a mounted resource through
its effective metadata origin to the declaring source context. The completed
main sequence replaces the lower-priority default collection as one value.
The maintained [executable example and behavior tests](../../examples/consumers/prompt_catalog_test.go)
show the scheduled result, empty-search inspection, excluded candidate, and
resource lookup without a consumer-side discovery pass. The corresponding
[complex fixture](../../examples/consumers/testdata/prompt-catalog/config.yml)
and [simple required-file fixture](../../examples/consumers/testdata/prompt-catalog/simple.yml)
are complete local examples.
[`LoadRequiredPromptCatalog`](../../examples/consumers/prompt_catalog.go) and
[`Example_requiredPromptCatalog`](../../examples/consumers/prompt_catalog_test.go)
keep the smaller adoption path visible: one explicitly required file import,
the same identity registration, and the same application validation.
The example's `validatePromptCatalog` remains application-owned. It checks that
prompt names and message lists satisfy this consumer's requirements after
ConfigKit has composed, projected, and decoded the configuration. It does not
add recursive record merging, identity-based overlay, or final uniqueness checks
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
[import syntax](../config.md#explicit-imports), and
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
boundaries.
Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime-
@@ -167,7 +294,9 @@ focused behavioral owner:
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
The package-level import-boundary test covers the consumer examples too: supported

View File

@@ -39,9 +39,11 @@ public behavior.
| Task | Read |
| --- | --- |
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
| Documentation changes | The owning document |
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
| Code and test changes | The owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release procedure](release.md) |
@@ -49,11 +51,18 @@ public behavior.
Accepted decisions:
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md)
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
- [Separate import selection controls](adr/0016-independent-import-controls.md)
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -103,7 +112,61 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
```
Remaining optimization candidates belong to
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
[future performance experiments](roadmap/future.md#3-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under
`docs/roadmap/`. The
[configuration contract](config.md#effective-configuration-diffs) owns current
comparison and disclosure behavior, and
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
The completed input-destination, relative-import-destination, and shared
source-version roadmaps, associated consumer request, implementation plan, and
resolved review findings are preserved in Git history at `e4270c9` under
`docs/roadmap/`. For example:
```sh
git show e4270c9:docs/roadmap/implementation.md
git show e4270c9:docs/roadmap/remediation.md
```
Current behavior belongs to the configuration contract's
[input destinations](config.md#input-destinations),
[imports](config.md#explicit-imports), and
[shared source-version checks](config.md#shared-source-version-checks) sections.
Explicit sequence-item placement and registered strict composition are owned by
the [strict composition](config.md#strict-document-composition) and
[imports](config.md#explicit-imports) sections. The
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
records the decision.
The completed sequence-composition roadmap, implementation plan, and resolved
remediation record are preserved in Git history at `008d2ba` under
`docs/roadmap/`. For example:
```sh
git show 008d2ba:docs/roadmap/sequence-composition.md
git show 008d2ba:docs/roadmap/implementation.md
git show 008d2ba:docs/roadmap/remediation.md
```
The completed import-discovery roadmap, implementation plan, and resolved
remediation record are preserved in Git history at `5747ec4` under
`docs/roadmap/`. For example:
```sh
git show 5747ec4:docs/roadmap/imports.md
git show 5747ec4:docs/roadmap/implementation.md
git show 5747ec4:docs/roadmap/remediation.md
```
The subsequent import-integration audit and completed remediation record are
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
`git show 6903c9e:docs/roadmap/remediation.md`.
Potential enhancements are tracked in [future features](roadmap/future.md).
Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline
@@ -151,17 +214,21 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command |
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |

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

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

View File

@@ -8,11 +8,23 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners:
- `internal/engine` owns host and native-filesystem source acquisition,
grouped strict composition, recursive precedence assembly, and destination-local
sibling inheritance before typed decoding. It also owns schema
grouped strict composition, recursive precedence assembly, per-input mapping
and sequence-item placement, definition-registered distinct-ID sequence
composition within strict tiers, and immediate-parent-relative import
placement within that assembly. Higher-priority sequences replace lower
sequences as whole values. The engine also owns dependency-ready,
destination-local sibling inheritance before typed decoding, plus shared
source-version checking with sanitized declaration evidence.
Explicit import discovery supports declared paths, directory searches, and
globs; load-wide defaults and source-local empty-selection evidence cross the
root facade without changing acquisition order. Import and inheritance
resolution share dependency-ready scheduling within their composition scopes.
It retains each source's effective placement alongside physical source
identity and owns schema
inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and
bootstrap, and safe inspection rendering. It never imports the root package.
bootstrap, safe inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
or records. Callback wrappers pass only live engine context during a callback;
@@ -21,6 +33,9 @@ Implemented component owners:
- Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods.
- Root `Diff` and `Difference` provide the public comparison boundary and
convert detached engine records to root-owned paths and snapshots; consumer
examples own presentation of the safe changed regions.
- `internal/document` owns the parser-independent ordered tree and cloning.
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
- Engine mechanism tests own source, composition, inheritance, decoding,
@@ -37,10 +52,27 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics. The [consumer adoption guide](../consumers/README.md) owns application
guidance. The [architecture policy](../policy/architecture.md) owns contributor
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
semantics, including [input destinations](../config.md#input-destinations),
[shared source-version checks](../config.md#shared-source-version-checks), and
[effective configuration diffs](../config.md#effective-configuration-diffs).
The [strict composition contract](../config.md#strict-document-composition)
owns sequence placement, registration, replacement, and history behavior; the
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
maintained example.
The [consumer adoption guide](../consumers/README.md) owns application guidance.
The [architecture policy](../policy/architecture.md) owns contributor
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
decision. See the [development guide](../development.md) for validation commands
and [release procedure](../release.md) for source-candidate gates. [ADR
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
post-assembly structural operation.
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
records the source-version decision.
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
records the shared dependency-ready ordering decision; [ADR
0016](../adr/0016-independent-import-controls.md) records the independent
selection controls. The [import internals](imports.md) document candidate and
namespace identities, discovery and scheduling, and the bounded algorithms.

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version
```
The module-selected executable is ReleaseKit v0.3.0. Use the built executable
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
for publication to preserve its exit status and publication-state diagnostics.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,894 @@
# Sequence Identity And Combination Implementation Plan
Status: ready for implementation; no stages completed.
Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit
`4f941f8ba4c9a9ed0afa87b07fe6ba29f131d1c6`. It implements the settled
[sequence roadmap](sequences.md), [ADR 0017](../adr/0017-collection-sequence-policy.md),
and [ADR 0018](../adr/0018-cli-after-structural-resolution.md).
## Working Rules
- Read [development guidance](../development.md) and the applicable policies,
especially [agents](../policy/agents.md), [architecture](../policy/architecture.md),
[testing](../policy/testing.md), and [documentation](../policy/documentation.md).
Prefer the repository knowledge graph for code discovery.
- The roadmap owns the selected feature semantics. This plan supplies execution
order and implementation choices, not additional product requirements. Preserve
unchanged source, parser, version, schema, ownership, and inspection contracts.
- Complete each stage's implementation and behavioral tests together. Record its
completion and validation here, then commit the nonempty unit with a short,
plain-English message. Preserve unrelated changes. Do not publish intermediate
stages or a release, update consumers' dependencies, or wait for CI.
- Run focused tests while iterating and `./scripts/check.sh` before handing off
each code stage. Use the installed compatible Go toolchain with dependencies
already acquired, as the development guide describes. Add race coverage at the
ownership milestones below. These are implementation checks, not new release
gates. Reuse successful checks for unchanged content rather than repeating them
at a documentation-only milestone. Do not require human platform validation.
- Update affected declaration comments and canonical current-behavior prose when
behavior lands. Keep incomplete target behavior in this roadmap/plan until it
works. The final documentation stage reconciles the cross-feature narrative;
it is not permission to leave misleading current contracts in earlier stages.
- Transitional internal adapters are acceptable where specified. Remove them by
completion. Do not add public compatibility aliases, permanent feature flags,
alternate semantics, new dependencies, new resource caps, or an extension/plugin
framework for this work.
- Preparation stages keep existing public behavior working and test new machinery
privately. Activation stages integrate values, policy, metadata, and relevant
failure handling together; do not defer correct inspection to a later stage.
## Implementation Decisions
### Public Registration Surface
Replace `SequenceComposition` and `Definition.SequenceCompositions` with:
```go
type SequenceIdentity struct { /* private, root-owned registration state */ }
type SequenceSelector []SequenceSelectorSegment
type SequenceSelectorSegment struct { /* private literal-or-any-mapping-key state */ }
func SequenceLiteralKey(key string) SequenceSelectorSegment
func SequenceAnyMappingKey() SequenceSelectorSegment
func SequenceIdentityAt(path Path, fields ...string) SequenceIdentity
func SequenceIdentityMatching(selector SequenceSelector, fields ...string) SequenceIdentity
// Field on Definition[T]:
// SequenceIdentities []SequenceIdentity
```
For example, the reusable definition can contain both registrations:
```go
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
},
```
The exact constructor converts mapping-only `Path` segments into literal selector
segments. Preserve invalid index segments long enough to report an ordinary
definition error; do not panic, silently drop them, or change `Path` semantics.
The zero selector is the exact root selector, harmless under the existing
mapping-root schema. A zero selector segment denotes the literal empty key,
not a wildcard. Empty field names are valid literal field names; an empty field
set is invalid. Constructors snapshot slices; load-entry conversion also takes
an owned snapshot. Definition validation, not construction, reports errors.
Root types and functions must not expose internal types or bridge methods.
### Identity Representation And Matching
- Normalize identity field sets once: deduplicate and sort exact field names.
Use the same normalizer for YAML and application registration.
- Encode a record's identity as a hashable, type-tagged, length-delimited tuple in
normalized field order. Mapping lookup indexes avoid scanning record fields
repeatedly. Distinguish structural-payload lookup from final typed-payload and
parent-reference lookup: only fields acting as directives are excluded, not
every occurrence of the same literal spelling. Never include tuple encodings
in diagnostics.
- Normalize finite JSON numbers to sign, significant decimal digits, and a signed
base-10 exponent. Strip insignificant zeroes and give all zero spellings one
representation. Use `math/big.Int` for exponent arithmetic, not a power-of-ten
expansion or a float conversion. Thus large exponent spellings do not allocate
storage proportional to their mathematical magnitude. This comparison-only
representation does not rewrite document nodes or typed projection.
- Compile selectors into a mapping-segment trie with literal and wildcard edges.
Carry applicable trie states through traversal; stop at sequence-index edges.
Index concrete matches within one assembly/inheritance scope where useful, but
do not save an assembly-only destination list for final validation.
- Check registration overlap pairwise once during definition validation using
equal length and compatible corresponding segments. This straightforward
`O(R² × D)` check, for R registrations and maximum selector depth D, is
proportionate for registration definitions. Equivalent constraints coalesce;
conflicting overlaps fail even with no matching input.
Do not impose a registration-count limit to simplify this operation.
### Policy And Evidence Storage
Keep application constraints separate from YAML policy. Add private engine
collection-policy records containing normalized identity, explicit mode, and
declaration locations. Track whether identity is pending or belongs to a retained
collection. Retain declaration presence separately from inferred defaults.
Use the existing acquisition/item scopes as ownership anchors, with an opaque
per-load owner token when a retained record needs one. Store item-local policies
relative to their owning record, and project concrete paths when that record is
placed. Do not key ownership solely by numeric index, identity tuple, or source
filename: all three can coincide for a replacement that must lose old policies.
Inheritance clones the relevant scoped state; it does not create registrations.
Resolve policies through shared engine helpers used by strict composition,
precedence, and inheritance. Do not attach engine policy to public values or add
policy fields to the parser-independent `document.Node`. The sidecar can be
extended with concrete-path indexes; it need not become a general event store.
Sequence combination returns the resulting list plus explicit input-to-output
item projections and retained/replaced contribution information. Use that same
result for values, policy transfer, origins, source destinations, sensitivity,
and historical replay. Extend the existing sequence-history descriptors for
noncontiguous upsert positions rather than inferring them from equal values.
Sharing immutable internal evidence is acceptable under existing ownership rules;
independently returned mutable records must remain detached.
### Diagnostics
Use existing `options.invalid` for invalid application definitions and existing
`compose.invalid` / `compose.conflict` for malformed or duplicate strict-peer
contributions. Add root-owned and engine diagnostic constants
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
(`sequence.conflict`) for malformed collection declarations/operations or final
identities, and conflicting policy / precedence duplicates respectively.
Final duplicate identities use `sequence.conflict`. Keep existing inheritance
invalid/missing/cycle codes for record relationships. Use existing override and
schema/decode codes at the CLI boundary; do not invent a new error taxonomy there.
Report collection/item paths and available locations, not identity values,
normalized tuple strings, raw parent references, or raw hook errors. Declaration
conflicts include both sites. Definition overlap errors identify registration
positions without fabricating a concrete matched configuration path. Exact
English diagnostic wording is not a new guarantee.
## Stage 1 — Implement Shared Scalar Identity Primitives
Scope: new private identity helpers and their focused tests; no load-path change.
1. Add `internal/engine/sequence_identity.go`. Implement field-set normalization,
scalar tuple construction, and exact decimal normalization as chosen above.
Accept string, number, boolean, and explicit null nodes; distinguish categories
and absence. Exclude fields acting as structural directives from payload lookup.
2. Validate mapping records, complete direct-field sets, and uniqueness using one
tuple-to-first-record index. Return paths and source locations, not values.
Support caller-selected diagnostic families for strict and other boundaries.
3. Cover equivalent field sets; empty strings and null; missing/non-scalar fields;
literal punctuation; compound tuple boundaries; `1`/`1.0`/`1e0`; signed zero;
close unequal decimals; and large exponents without expansion. Verify that
node text remains unchanged. Include `extends: null` as a structural field
that cannot supply source identity.
Validation: `go test ./internal/engine -run 'SequenceIdentity|SequenceNumber'`
and the everyday check. Retain the old registration path until its adapters are
replaced; do not generalize unrelated YAML scalar decoding.
## Stage 2 — Compile Exact And Dynamic Application Constraints
Scope: private registration model, selector matching, overlap validation.
1. Extend `sequence_registration.go` with the common registration representation
and compiled selector trie. Represent literal and arbitrary-mapping-key segments
structurally, not with a magic string. Snapshot registration inputs.
2. Validate field sets and mapping-only selectors before source acquisition.
Preserve existing destination validation without adding restrictions on identity
field spelling. Root and unused selectors remain harmless.
3. Implement full-destination matching, equivalent overlap coalescing, and
incompatible-overlap errors. Exact rules have no priority over dynamic rules.
Keep a temporary conversion from the current single-field registration so
existing callers still use their current behavior.
4. Test exact and wildcard matches, multiple wildcard levels, empty/punctuation
keys, sequence-index nonmatches, disjoint lengths/literals, equivalent sets,
incompatible overlaps with absent input, and independent uniqueness scopes.
Validation: focused registration/selector tests and the everyday check. This
stage does not export the replacement API or add final-result validation yet.
## Stage 3 — Parse Collection Declarations Without Filesystem Coupling
Scope: private parser and directive extraction helpers, initially unit-tested
without activating collection combination in public loads. Keep source-form
activation out of this stage; ordinary sources and selected overlays are wired
in Stages 13 and 14 respectively.
1. Add `sequence_declaration.go` for source-root `configkit.sequences`. Each entry
has a required existing literal `destination` form and optional `key` and
`mode`. `key` is a string or nonempty string list; `mode` is exactly replace,
append, or upsert. Use shared identity normalization. A destination-only entry
can be a harmless no-op; do not add an unrequested ban on empty declarations.
2. Split common root-directive extraction from import-specific parsing in
`import_spec.go` / `parse.go`. Extract imports and sequences together, retaining
source-local locations and explicit presence. Reject unrecognized directive
fields and wrong supplied field types using existing strict parsing style.
3. Keep declaration destinations relative to source placement, with item-local
coordinates retained until their scope is assembled. Do not project them
prematurely to a guessed numeric index.
4. Give extraction an explicit overlay context: sequences are allowed, imports
are not. Import enablement remains separately enforced; do not turn on
filesystem access because a source declares sequences. Keep enabled
source-version checks in raw source coordinates before consuming controls;
extend existing actual header/control-overlap handling to sequence directives
without treating an absent header beneath a control as an actual overlap.
5. Test key-only, mode-only, combined, repeated-field-set equivalence, no-op,
malformed types/modes/destinations, mixed imports/sequences, and overlay rules.
Do not add import-level key/mode shorthand or sequence wrappers.
Validation: focused declaration/parser tests, existing import-spec tests, and the
everyday check. Existing import-only behavior remains intact during preparation.
## Stage 4 — Reconcile Declarations And Select Collection Operations
Scope: pure agreement and operation-selection helpers, independent of loading
and of the ownership transformations introduced next.
1. Add `collection_policy.go`. Gather the current scope's declarations before
combining peers. Reconcile equivalent keys and agreeing explicit modes;
conflicting peers fail. Omitted fields have no preference. Apply authoritative
application constraints even for key-only declarations without a value.
2. Implement shared operation selection: current explicit mode, retained explicit
mode, inferred upsert with effective identity, otherwise replace. Persist only
explicit modes. Policy-only declarations must not replay earlier combinations.
3. Accept eligible lower policy as an input; do not decide record survival or
ancestor removal inside declaration reconciliation. Retain declaration
locations/presence separately from inferred mode and application constraints.
4. Test equivalent/different field sets, agreeing/conflicting peer modes,
omission, explicit default modes, and operation inference. Include key-only
declarations agreeing or conflicting with application constraints before a
sequence exists.
Validation: focused policy-agreement/selection tests and the everyday check.
Do not activate collection combination or implement the ownership layer here.
## Stage 5 — Track Policy Lifetime And Record Ownership
Scope: private scoped-state transformations built on Stage 4's resolution helpers.
1. Implement key agreement across retained values, replacement reset, pending
key declarations, and immediate validation when a key-only declaration
identifies retained records.
Changing mode to replace without a replacement value does not reset identity.
2. Implement scope/record ownership transfer and retirement hooks. Actual ancestor
replacement removes descendant collection identity and removed-record policy;
independently owned modes and application constraints survive as specified in
the roadmap. Do not repeatedly reapply old declarations and resurrect reset
identity. Pending declarations retain their owning-scope rules.
3. Use item/acquisition scope anchors and record-owned relative policy as selected
above. Provide transformations for retention, whole-record replacement,
whole-sequence replacement, and inheritance copies without wiring them into
public loading yet. Application registrations remain destination constraints.
4. Use table-driven tests derived from the roadmap's lifetime, persistence, and
replacement tables, including absent/non-sequence destinations, equal outer
identities with distinct record owners, and independent root versus item policy.
Validation: focused collection-policy tests and the everyday check. This stage
must not add path-existence, shape, nullability, or global YAML final-check rules.
## Stage 6 — Implement The Shared Sequence Operations
Scope: a pure document-level combination helper, not separate implementations
for overlays and inheritance.
1. Add `sequence_combine.go`, accepting lower/higher sequence nodes, resolved
policy, and combination context. Reuse Stage 1 identity extraction and indexes.
2. Implement replace, duplicate-rejecting append, and whole-record upsert. Upsert
retains matched lower positions and appends new higher identities in order.
Return item projections and replacement/retention evidence with the new value.
3. Keep peer validation distinct: duplicate identities within any contribution or
among peers fail under every mode. Do not merge record payloads or sort by ID.
4. Cover absent versus null/non-sequence lower values; higher omission, `[]`, null,
and non-sequence values; missing identity under append/upsert; empty inputs;
stable ordering; equivalent/different keys; and explicit replacement identity
reset. An empty input does not bypass kind checks or identity requirements.
5. Verify clones do not alias mutable input nodes or policy slices. Build indexes
once per operation rather than scanning the accumulated list for every record.
Validation: focused combination tests and the everyday check. Do not change the
ordinary recursive mapping rule or add unkeyed append.
## Stage 7 — Extend Sequence History Representation And Replay
Scope: represent and replay sequence operations without yet changing all metadata
projection consumers or public precedence.
1. Extend `sequenceHistoryContribution`, sequence replay in `history.go`, and
its contribution indexes to consume Stage 6's explicit item mappings. Existing
append ranges may remain a compact case; upsert cannot be represented by one
offset.
2. Preserve authored raw contribution snapshots and displaced values. A replaced
record's omitted fields must appear as removed, not as retained lower payload.
Preserve equal-valued replacement events. Keep existing index-based query and
atomic sequence-diff APIs; add no public identity-selector path syntax.
3. Exercise synthetic append/upsert/replace histories, noncontiguous matches,
nested paths, and discarded descendants. Compare replayed final values with
the actual combination result, including first/last and unchanged neighboring
records. Reuse existing escaped-path and detached-query tests rather than
duplicating them.
Validation: sequence/history/replay tests and the everyday check. Keep current
append/replace callers working through the extended descriptors.
## Stage 8 — Project Sequence Origins And Inspection Evidence
Scope: use the prepared item mappings consistently in metadata consumers.
1. Project composition origins, nested source destinations, historical paths, and
sensitivity through Stage 6's item mapping and Stage 7's history descriptors.
Physical source IDs/locations and resource contexts continue to identify the
declaring source. Reuse one projection mechanism rather than searching again
by identity or equal payload.
2. Test synthetic noncontiguous upserts, replaced descendants, nested source
destinations, unchanged neighbors, and secret-marked replaced records. Verify
projected queries agree with replayed and actual final values, retaining
displaced evidence and detached results.
3. Keep the new precedence operations unactivated until their value, policy, and
projection machinery are integrated in the later load-path stages.
Validation: metadata/history/sequence/provenance/sensitivity tests,
`go test -race ./internal/engine`, and the everyday check.
## Stage 9 — Separate Per-Input Acquisition From Composition
Scope: a behavior-preserving acquired-input handoff; grouped underlay coordination
is the next stage.
1. In `load_input.go`, `imports.go`, and related graph helpers, retain acquisition
scopes, raw contributions, inclusion edges, source records,
contexts, and extracted declarations without irrevocably composing them.
Reuse existing `deferAssembly` and acquired-scope mechanisms instead of adding
a second loader. Keep already acquired document handles closed as before.
2. Separate acquisition from the per-input composition entry. Retain pending
item/helper scope boundaries, namespace identities, inclusion/repetition checks,
source records, accounting, and scheduler inputs. Do not reacquire sources to
compose them later or change grouped coordination yet.
3. Preserve existing version checks for this refactor. Retain a small adapter
invoking current composition semantics at the existing lifecycle point.
Preserve source-version callbacks at acquisition, including header consumption.
4. Run existing acquisition, dependency scheduling, item placement, source-version,
ordinary/layered equivalence, resource-lifetime, and source-context tests. Add
only targeted regressions for any newly split handoff not already covered.
Validation: the focused suites above and the everyday check. This refactor must
not change source IDs or turn acquisition order into precedence order.
## Stage 10 — Separate Underlay-Group Acquisition From Composition
Scope: behavior-preserving group coordination in `load_request.go`, using the
per-input handoff already prepared in Stage 9.
1. Separate acquiring an underlay group from scheduling/composing its acquired
scopes. Preserve namespace reanchoring, shared inclusion/repetition state,
item-local helper scopes, source/context aggregation, and group accounting.
2. Keep authored acquisition order: main and depth-first imports, each underlay
and input, overlay, then synthetic CLI records. Preserve underlay indexes and
current version-check timing in this refactor; policy-dependent layered
composition is activated only in Stage 15.
3. Expose the common scope-composition handoff with inputs for accumulated lower
policy and compiled application constraints. Initially adapt it to the current
semantics. Do not add another loader or repeat acquisition/version callbacks.
4. Test the split with existing grouped/dependency/version/destination coverage,
including empty inputs/groups and repeated sources within versus across tiers.
Confirm unchanged values, source ordering, contexts, and failure evidence.
Validation: grouped/layered, import scheduling, version, destination, and
resource-context tests plus the everyday check.
## Stage 11 — Compose Strict Collections With Resolved Identity
Scope: the policy-aware strict composition core. Nested pending-item scope
integration is deferred to Stage 12, not absorbed into this prompt.
1. Extend `composeDocumentsWithPolicy` and `validateRegisteredSequences` to use
normalized field sets and scalar tuples. Resolve current declarations,
applicable application constraints, and eligible lower identity before peer
composition, using the Stage 4 agreement and Stage 5 lifetime helpers.
2. Use the existing dependency-ready order for contributions and authored list
order. Validate every identified sequence, including singleton contributions;
strictly append distinct peers and reject duplicates under all modes.
3. For explicit replacement, do not use obsolete lower YAML identity to authorize
otherwise unkeyed peer composition. Still apply independently registered
application constraints and genuinely applicable declarations.
4. Attach Stages 7–8 evidence and Stage 5 scoped policy to composed results. Test
completed-document contributions with lower-established identity, declaration
conflicts, compound/scalar IDs, singleton validation, and replacement resets.
Validation: composition and sequence identity tests plus the everyday check.
Keep unregistered non-mapping conflicts unchanged. Public load activation remains
in the later integration stages.
## Stage 12 — Integrate Policy-Aware Item And Helper Scopes
Scope: adapt pending-item/helper composition to Stage 11's core using the acquired
scope boundaries from Stages 9–10.
1. Gather declarations before composing each relevant scope, complete item-local
helper imports before item placement, and consume applicable non-main version
headers before checking record payload identity. Keep relative policy with its
owner until concrete placement is known.
2. Apply eligible lower identity at each scope, but prevent a replacement outer
record from borrowing nested identity owned by the discarded lower record.
Preserve independently applicable policy and application constraints.
3. Retained structural directives cannot serve as identity fields. A helper may
supply payload identity components. Do not require a helper-built compound
parent reference to be complete at this point; validate the completed surviving
reference during inheritance.
4. Project item evidence and owned policies through Stages 7–8 machinery. Test
nested items, helper-supplied identity, header consumption, declaration scope,
discarded owner identity, and exact diagnostic paths with no value disclosure.
Validation: item/destination/import/composition/source-version tests and the
everyday check. Keep current public behavior until load-path activation.
## Stage 13 — Activate Ordinary Source Collection Declarations
Scope: ordinary `Load` base-source assembly, excluding activation of the new
selected-overlay policy surface until Stage 14.
1. Wire common directive extraction into every ordinary source form: file,
native filesystem, bytes, reader, mapping, imported mapping, and sequence item.
Declarations work without `Imports`; disabled imports retain their existing
rejection semantics. `NoSource` remains absence rather than an empty document.
2. Use Stages 11–12 composition and Stage 5 scoped policy. Strip collection
controls from application decoding, but retain their locations/evidence for
their structural lifetime and diagnostics.
3. Integrate policy/projections together with the values. Cover inline/imported
equivalence, placement-relative declarations, singleton and peer validation,
and declarations without accompanying values. Maintain ordinary loading's
existing non-collection overlay behavior while preparing its policy handoff.
4. Update the implemented declaration/composition contract sections. Collection
precedence, inheritance, and later CLI behavior remain incomplete until their
integration stages; do not claim them implemented here.
Validation: ordinary-load, import, composition, and metadata tests plus
the everyday check. Include one public ordinary-load declaration example test.
## Stage 14 — Activate Selected Overlay Collection Policies
Scope: ordinary loading's selected overlay and shared precedence integration,
using the source/declaration handoff already active in Stage 13.
1. Permit `configkit.sequences` in selected overlays while continuing to reject
overlay imports. Preserve source-version header/control handling and ordinary
overlay namespace selection. Consume controls before application decoding.
2. Use Stage 6's sequence operations and Stages 4–5 policy helpers in the assembly
accumulator. Mappings still recurse and unrelated sequences still replace.
Wire value, policy retirement/transfer, and Stages 7–8 inspection projections
together, not as a later best-effort repair pass.
3. Cover append/upsert/replace, empty/null/kind cases, key-only/mode-only changes,
retained explicit modes, overlay identity agreement, and independently owned
policies. Check values, history, origins, and discarded descendants together.
4. Update the selected-overlay and collection-operation contract sections. Make
the shared precedence entry reusable by layered loading without adding another
operation implementation or turning overlay imports on.
Validation: ordinary-load, overlay, composition, policy, and metadata tests plus
the everyday check.
## Stage 15 — Integrate Lower Policy Across Layered Assembly
Scope: source-backed underlay coordination and the layered selected-overlay path,
using the prepared acquisition, ownership, strict-scope, and precedence machinery.
Do not absorb unfinished work from Stages 5–14 into this stage.
1. Acquire main first, but assemble underlays from lowest to highest, strictly
composing each group with applicable accumulated lower policy before applying
its one precedence operation. Then compose/apply main and the selected overlay.
Do not sequentially upsert peer sources as if they were different tiers.
2. Pass lower identity into higher strict scopes before peer composition. Cover an
otherwise empty underlay establishing identity, inherited explicit mode, and
replacement that deliberately discards the old YAML key. Invalid lower tiers
still fail even if a higher replacement would hide their values.
3. Keep main-only version ownership and every enabled per-source version rule.
In default source-version mode, run the main checker once on the composed
main tier before overlay/inheritance/defaults, never on underlay-augmented
values. Its former main-before-underlay composition timing need not persist:
main composition now depends on lower policy. Enabled-mode source checks keep
acquisition order and exact source-local header handling. No extra callbacks
or second acquisition pass are permitted.
4. Keep histories in precedence order, sources in acquisition order, underlay
indexes stable across empty groups, and shared budgets intact. Update the
layered contract's acquisition-versus-composition description accordingly.
Remove the acquisition/composition adapters superseded by this integration;
the old public-registration adapter remains until Stage 22.
5. Cover multiple inputs/groups, mounted main inputs, item scopes, empty main,
explicit empty groups, layered overlays, version protection and diagnostics,
and equivalence with ordinary loading where the inputs express the same model.
Validation: layered/grouped, source-version, dependency scheduling, destination,
and metadata tests plus the everyday check.
## Stage 16 — Verify Replacement And Nested Policy Ownership End To End
Scope: the consequential lifetime cases across the now-active source assembly.
1. Exercise an item-local nested collection through retaining the outer record,
upserting the same outer ID, and replacing the entire outer sequence. Retained
items keep their local key/mode; replacements do not inherit discarded policy
merely because identity, source label, or final index is equal.
2. Exercise ancestor replacement and later reintroduction. Removed collection
identity does not reappear; independently declared append mode can survive
and require renewed identity. Cover renewed YAML key, application constraint,
explicit replace, and the intended failure with no available identity.
3. Cover key-only validation of retained records, pending declarations, a no-value
replace declaration that cannot authorize key changes, and independent policy
whose owning scope survives a record replacement.
4. Correct any integration defects in the Stage 5 ownership transformations;
consolidate tests with their mechanism owners. Do not introduce special-case
ownership by numeric path or a historical identity cache to make cases pass.
5. Verify frozen metadata, origin/resource context, sensitivity of discarded
records, and mutable independence across repeated loads for these scenarios.
Validation: focused policy/sequence/layered/ownership tests,
`go test -race ./internal/engine`, and the everyday check.
## Stage 17 — Prepare Inheritance Policy And Template Transport
Scope: behavior-preserving inheritance state transport, before activating
collection-aware inheritance operations in Stage 18.
1. Extend inheritance templates/expansion sidecars to carry collection identity
and record-local policy alongside nodes and origins. Keep existing scheduler
keys, cancellation, version protection, missing-parent/cycle diagnostics, and
destination-local evaluation of nested templates.
2. Provide owned-state copy/transfer helpers from Stage 5 and path projections from
Stage 8. Preserve original nested instructions for each destination's later
evaluation rather than retaining only a parent's expanded output. Keep
application registration matching separate from copied YAML policy.
3. Test template reuse at multiple destinations, independent scoped state, and
retained instruction locations/resource context. Preserve current inheritance
values/history while these new helpers remain preparatory; do not activate a
value change without its corresponding policy/evidence handling.
Validation: existing inheritance, ownership, history, and resource-context tests,
focused transport tests, and the everyday check.
## Stage 18 — Apply Collection Policy During Mapping-Sibling Inheritance
Scope: activate shared collection operations using the prepared template/state
handoff; existing mapping-sibling relationship selection remains unchanged.
1. Use the shared sequence combiner at the inheriting destination: parent lower,
child higher, destination explicit mode or identity-based default. Copy YAML
identity where applicable, but do not automatically copy a parent collection's
explicit mode to a different destination. Match application constraints at the
destination, including newly created matching paths.
2. Transfer record-owned state only with surviving records using Stage 17's
helpers. Integrate inheritance evidence with Stage 8's projections in the same
activation, preserving origins, historical sensitivity, resource context,
cancellation, and existing expansion/history accounting.
3. Cover nested append/upsert/replace, explicit empty/null/kind cases, copied key
with inferred upsert, destination explicit mode, incompatible keys, application
constraints, cancellation, and policy at an inherited new namespace.
4. Update current mapping-sibling inheritance documentation for collection
operations. Identified-record parent lookup remains incomplete until Stage 21.
Validation: inheritance, history, provenance, sensitivity, and resource-context
tests plus the everyday check. No identified-record parent lookup is active yet.
## Stage 19 — Build Identified-Record Inheritance Graphs
Scope: relationship extraction/validation and shared scheduling for sequence
scopes; test these helpers before activating payload expansion.
1. Extend sibling-scope discovery to identified sequences. Build the tuple-to-item
lookup once using effective identity. Every record supplies its own complete
identity before inheritance; neither parent lookup nor typed defaults supplies
a missing child field.
2. Parse a surviving record's `extends` as null cancellation or a mapping with
exactly the normalized identity fields and supported scalar components.
Mapping-sibling references keep their existing string form. Do not accept a
scalar shorthand, multiple parents, positional or cross-collection lookup.
3. Parent-reference mappings have already used ordinary strict mapping
composition: disjoint helper fields can complete one reference, while equal
overlapping fields conflict. Validate completeness/extra fields only now on
the surviving reference. Discarded references remain historical evidence.
4. Build one-parent dependency edges and reuse `scheduleDependencies` with stable
semantic-pointer keys. Missing parents, self-reference, and longer cycles
use existing inheritance diagnostics without identity values. Keep authored
output positions independent from graph schedule.
5. Cover compound and null references, numeric equality, forward edges, chains,
unrelated equal identities in different lists, malformed refs, missing parents,
cycles, helper-built references, and overwritten/cancelled relationships.
Validation: focused inheritance graph/reference tests and the everyday check.
## Stage 20 — Prepare Reusable Inheritance Scope Execution
Scope: remove mapping-container assumptions from the private template executor
without activating identified-record inheritance in public loads.
1. Refactor template execution to separate relationship scheduling/payload
expansion from reading and rebuilding its scope container. Preserve current
mapping-sibling output, locations, cancellation, evidence, and budgets.
2. Provide private mapping- and identified-sequence-scope adapters using Stage 19's
relationship graphs. The sequence adapter preserves output positions and
exposes child identity fields to the payload-expansion step. Keep this private;
do not add a public generic inheritance or merge callback API.
3. Reuse Stage 17's template/policy transport and Stage 18's collection-aware
payload operations. Existing origin/history/retained-node handling must remain
available to both adapters rather than duplicating an entire executor.
4. Run existing mapping-inheritance regressions and privately exercise a small
sequence graph through the adapter. Check output order, independent templates,
and operation/evidence paths before public activation in Stage 21.
Validation: inheritance execution, scheduling, history, and budget tests plus
the everyday check. This refactor does not introduce new parent-reference forms
or change public loading semantics yet.
## Stage 21 — Resolve Record Inheritance With Ownership And Evidence
Scope: integrate Stage 19's graphs and Stage 20's executor into the structural
expansion phase, with values and inspection working together.
1. Expand parent payload into the child while preserving the child's own identity
fields and their origin/presence. Exclude parent identity fields from payload
inheritance. Recursively combine mappings and use destination collection policy
for nested sequences; retain whole-record upsert at the earlier boundary.
2. Consume the child's directive before decoding. Keep parents as ordinary records
subject to the same schema/semantic validation; create no abstract templates or
acquired source records.
3. Resolve only records surviving tier assembly. Cover parent upsert/removal,
replacement of a child without `extends`, self-reference not exposing a lower
predecessor, and nested destination-local relationships. Enclosing scopes
finish before nested scopes; assembled list order stays unchanged.
4. Carry policy, origin/history, sensitivity, resource context, and existing
expansion/retained-history accounting through copies. Do not widen budgets or
require a new inspection-path API.
5. Add public-load regressions for complete inheritance chains and nested
collections, including secret canaries on success and failure. Update the
implemented inheritance contract and consumer guidance for this scope.
Validation: all inheritance and cross-feature inspection tests,
`go test -race ./internal/engine`, and the everyday check.
## Stage 22 — Expose The Root-Owned Registration API
Scope: the settled facade surface and migration of in-repository callers.
1. Implement the exact public names/signatures above in a focused root file, and
add `Definition.SequenceIdentities`. Convert root records/selectors explicitly
into the engine model. Snapshot fields, selectors, and paths on construction
and loading, preserving immutable definition reuse.
2. Remove `SequenceComposition`, `Definition.SequenceCompositions`, and the private
old-shape adapters. Migrate all maintained code/tests/examples; do not edit
historical release notes solely to erase the old name or add deprecated aliases.
3. Cover mixed exact/dynamic constructors, compound scalar fields, literal `*`
versus wildcard segments, root/unused selectors, invalid index paths and empty
field sets, overlap failure before source access, and independent loads.
4. Prove dynamic constraints apply before strict composition and at inherited
destinations, without consumer namespace enumeration. Keep final-result
enforcement described as pending until Stage 27 actually adds it.
5. Verify the public boundary and declaration documentation. Do not expose engine
enums, policy stores, parser nodes, or internal methods.
Validation: root sequence/facade, registration, public/dependency-boundary, and
consumer example tests plus the everyday check.
## Stage 23 — Separate Reusable CLI Override Preparation
Scope: mechanical preparation refactor and a privately tested post-structural
validation entry, without changing current CLI processing order.
1. Refactor override preparation so labels, paths, snapshots, type support,
disjointness, version protection, and existing budgets remain reusable outside
the source assembly accumulator. Allocate synthetic sources once in the same
order; do not reopen or reinterpret CLI values as YAML.
2. Prepare post-structural validation that rejects `extends` as well as
`configkit` in supplied paths and nested values, using their established
structural roles. Test this entry privately; retain the existing entry's
behavior until the lifecycle switches in Stage 26. Do not reject previously
valid early-CLI instructions before that switch or add unrelated restrictions.
3. Test detached prepared batches, synthetic source ordering, and the new
directive validation. Reuse existing label/path/cycle/type-support/budget tests
rather than adding another inventory of the same preparation cases.
Validation: override preparation/source tests and the everyday check. Keep
ordinary/layered behavior unchanged; typed assignment is the next stage.
## Stage 24 — Implement Schema-Aware Post-Decode CLI Assignment
Scope: a private typed assignment helper using Stage 23's prepared overrides,
without activating the later lifecycle order yet.
1. Add `applyDecodedOverrides`, using existing schema/decode mechanisms to bind
only supplied override subtrees against a detached copy of the decoded state.
Mappings recursively overlay, sequences replace, and explicit zero/empty/null
retain normal schema rules. Current typed values supply retained children,
not new defaults for replaced list items. Do not call `Definition.Defaults`
again or re-decode the whole projected configuration as newly authored input.
2. Return the updated typed value, canonical post-CLI projection, presence/origin
changes, and existing raw CLI history contributions. Preserve a pre-CLI typed
projection for decoding reconciliation/history. No resolver runs on a failed
override; no partially applied result is returned.
3. Test unknown/type-invalid fields, mapping retention, sequence replacement,
nested maps/lists, named/text/duration/secret schema fields, explicit null/false/
zero, discarded children, structural controls, and mutable ownership.
Validation: override, decode, schema, projection, and ownership tests plus the
everyday check. Only the helper is added here; the shared lifecycle does not
switch until its inspection/context support is prepared in Stage 25.
## Stage 25 — Prepare Post-Decode CLI Inspection And Effective Context
Scope: prepare history, resolution-input, and secret-lookup context handling;
retain the existing public CLI lifecycle until Stage 26.
1. Extend existing replay/reconciliation to represent the target order:
source tiers, inheritance, typed decoding/defaults, CLI, application resolution.
Keep the structural input, pre-CLI typed snapshot, raw CLI contributions, and
post-CLI projection distinct where needed. Use the existing history machinery,
not a second replay framework, and retain legacy input compatibility until the
lifecycle switch. Do not mislabel CLI changes as defaults or inheritance.
2. Prepare resolution-entry metadata from Stage 24's result so explicit presence,
origins, source records, and resource contexts describe the overridden state.
3. Prepare the effective reference/location handoff for secret lookup and final
validation using the post-override/post-resolution values, not stale structural
input. Preserve existing no-credential-byte and ownership behavior.
4. Test the target pipeline with privately assembled snapshots: equal-value CLI
replacement, replaced descendants, decoding reconciliation before CLI, later
recorded resolver transformations, explicit-presence defaults, and an overridden
secret reference. Verify current public-load regressions still pass unchanged.
Validation: history/metadata/resolution/secret tests and the everyday check.
Public activation must not require a later stage to repair these mechanisms.
## Stage 26 — Activate CLI After Decoding In The Shared Lifecycle
Scope: activate ADR 0018 through both loading paths using Stages 23–25's complete
assignment, inspection, and effective-value handoffs.
1. Remove CLI assignment from ordinary/layered structural assembly. Invoke
Stage 24 after inheritance and successful typed-default creation/decoding,
immediately before application resolution. Activate Stage 23's post-structural
validation. Keep the single shared lifecycle, not separate CLI implementations.
2. Wire Stage 25's history and context handoffs in the same change. Resolution
receives explicit-presence/origin metadata and effective CLI values;
`Resolution.Default` preserves them while recorded transformations can change
them normally. Secret lookup uses effective references and locations. Preserve
exactly-once default-factory behavior and synthetic source ordering.
3. Cover `NoSource`, ordinary/layered equivalence, CLI clearing/replacing an
identified list despite append/upsert mode, derived values seeing CLI input,
equal-value provenance, and resolver changes after CLI. Add explicit regressions
that CLI cannot fix earlier decode/inheritance errors or introduce/cancel a
relationship. Update obsolete early-CLI tests deliberately.
4. Update the lifecycle, override, history, and secret-reference contracts and
affected examples to describe the now-implemented order.
5. Remove the superseded early-CLI application paths, old preparation entry, and
temporary replay compatibility paths. Keep their still-relevant behavioral
tests under the new lifecycle rather than postponing code cleanup to the
documentation stage.
Validation: override/lifecycle/resolution/inheritance/history/secret tests,
`go test -race ./...`, and the everyday check.
## Stage 27 — Enforce Final Application Identity Constraints
Scope: the final-result guarantee, not another combination or inheritance phase.
1. Add shared final identity validation after effective secret lookup and before
application semantic validation/publication in the common lifecycle. Use the
canonical effective typed projection, which retains secret references rather
than credential bytes. Preserve the existing validated publication snapshot
and detachment behavior.
2. Traverse the actual final tree with the compiled selector trie, validating each
matching sequence independently with Stage 1 primitives. This must discover
paths introduced by typed defaults, CLI, and callbacks, not only assembly paths.
Support root/namespaced rules and unrelated unselected sequences.
3. Validate actual sequences only. Missing paths, null, and other shapes remain
the schema/application validator's responsibility. Identity extraction here
examines effective typed payload, not whether a field was authored. Do not
recheck YAML-only policies or every intermediate callback state.
4. Cover duplicate/missing/non-scalar identities produced at each late phase,
valid null/scalar identities, numeric equality after typed conversion, dynamic
namespaces created by each late phase, harmless unmatched rules, concrete-path
diagnostics with available origins, and secret-value non-disclosure.
5. Prove both `Load` and `LoadLayered` use this guarantee and return no usable
result on failure. Update registration comments and canonical contract with
this final boundary, excluding later caller mutations from the guarantee.
Validation: final identity, selector, facade, defaults/resolution/override, and
secret tests plus the everyday check.
## Stage 28 — Exercise Representative Consumer Workflows
Scope: a small number of meaningful integrations, not a Cartesian test matrix.
1. Extend `examples/consumers` with an executable source-backed catalog: underlay
records, main fragments, selected overlay append/upsert/replace, and explicit
record inheritance. Demonstrate complete replacement of a matched record,
stable order, and final inspection without application merge code.
2. Add a namespaced typed-map example using one wildcard registration plus an
exact root registration, with independent uniqueness. Show YAML-declared
identity without registration separately so the final guarantee is not implied.
3. Include one integrated CLI/resolution case and one representative provenance/
resource-context/secret-history case where those interactions add protection
beyond mechanism tests. Keep application domain validation recognizable.
4. Audit coverage against the roadmap's work inventory, especially helper-built
parent references, version-header consumption, pending declarations, lower
identity before higher peers, replacement ownership, and dynamic destinations
introduced through inheritance and late typed changes. Add only missing,
consequential regressions at their narrowest stable owners.
5. Consolidate obsolete/brittle tests. Do not restore old replacement semantics
merely to keep fixtures unchanged, and do not preserve duplicates of the same
semantic table across every layer.
Validation: `go test ./examples/consumers`, focused root integration tests,
`go test -race ./...`, and the everyday check.
## Stage 29 — Reconcile Documentation
Scope: finish the current-contract narrative. Code cleanup belongs with the
integrations that supersede each adapter, not in this documentation prompt.
1. Reconcile `docs/config.md` for declarations, identity equality, selectors,
operations/persistence, ownership, inheritance, final validation, and CLI order.
Keep its operation and presence tables authoritative; Go comments own exported
symbols and consumer guidance links rather than copying full contracts.
2. Update `docs/consumers/README.md`, relevant root examples, `README.md`,
`docs/internal/overview.md`, `docs/internal/imports.md`, and
`docs/development.md` where the implemented structure or reading/test map has
changed. Remove stale claims about string-only registration, universal sequence
replacement, early CLI, and overlay prohibition of all directives.
3. Remove implemented portions from the deferred identified-record discussion in
`future.md`, retaining genuinely deferred recursive body merging and other
still-relevant ideas. Preserve accepted ADR decision content; update only status
or implementation references where appropriate. Architecture already adopts
the decisions: remove pending-implementation wording when true, without adding
new policy. Leave release version selection/publication to the release task.
4. Search maintained prose/examples for obsolete registration names and lifecycle
claims; retain intentional historical references. Follow canonical ownership
rather than copying the complete contract into each guide.
Validation: local documentation-link checks and whitespace review; run relevant
example tests if executable examples changed. Reuse Stage 28's successful
code/race validation when its covered content is unchanged.
## Stage 30 — Audit Scaling And Verify The Completed Target
Scope: a bounded implementation review and final validation, not another broad
refactor or expansion of the feature/performance scope.
1. Inspect the completed code for duplicate combination/identity algorithms,
per-record scans of whole collections, repeated declaration gathering, numeric
magnitude expansion, stale ownership/path caches, and unnecessary copies.
Prefer shared indexed mechanisms without pooling/global caches. Reuse existing
composition/underlay/inheritance benchmarks and add a focused mixed-upsert or
wildcard-collection benchmark only where existing fixtures cannot exercise the
new scaling risk. Record commands and qualitative findings here; timing or
allocation thresholds and a quiet-host benchmark campaign are not gates.
2. Confirm transitional adapters/dead early-CLI paths have been removed. Search
current code and maintained examples for `SequenceCompositions` and
`SequenceComposition`; only intentional historical references may remain.
Confirm no new dependencies, public internal-type exposure, background work,
release machinery, or additional limits were introduced. Review the full diff
against the roadmap, not merely passing tests. Record bounded review findings
here; significant corrective
work requires its own focused unit rather than being hidden inside this stage.
Do not declare completion while a target behavior remains incorrect or missing.
Validation: `./scripts/check.sh`, `go test -race ./...`, and the development
guide's local documentation/public-boundary checks, reusing successful checks
from Stages 28–29 if their covered content is unchanged. Record completed scope,
validation, benchmark commands/qualitative findings, and any real remaining
issues accurately. No new timing thresholds or release gates are introduced.
Acceptance: all roadmap operations and lifecycle boundaries work through ordinary
and layered loads; values and inspection agree; application constraints hold on
successful final results; deferred behavior and YAML-only guarantees have not
expanded. All thirty stages are complete and their nonempty units committed.
## Open Questions
None. Feature choices are settled in the roadmap and accepted ADRs; this plan
selects the remaining API, representation, validation, and execution decisions.

999
docs/roadmap/sequences.md Normal file
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

705
facade_destination_test.go Normal file
View File

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

327
facade_diff_test.go Normal file
View File

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

View File

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

View File

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

View File

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

119
facade_import_root_test.go Normal file
View File

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

View File

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

View File

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

661
facade_sequence_test.go Normal file
View File

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

View File

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

2
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
package engine
import (
"reflect"
"testing"
)
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
names := []string{"a-base", "b-independent", "z-dependent"}
keys := make(map[string]importNodeKey, len(names))
contributions := make([]acquiredContribution, len(names))
for index, name := range names {
key := importNodeKey{
namespaceKind: "host", namespaceAnchor: "/shared",
resourceKind: "file", resourcePath: "/" + name,
}
keys[name] = key
contributions[index] = acquiredContribution{
identity: key,
source: SourceInfo{ID: "source-" + name, Path: name},
}
}
var want []string
for _, reverse := range []bool{false, true} {
graph := newImportInclusionGraph()
orderedNames := append([]string(nil), names...)
if reverse {
orderedNames[0], orderedNames[2] = orderedNames[2], orderedNames[0]
}
for _, name := range orderedNames {
graph.addNode(importGraphNode{key: keys[name], sourceID: "source-" + name, role: importRoleMapping})
}
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, graph)
got := make([]string, len(scheduled))
for index, contribution := range scheduled {
got[index] = contribution.identity.resourcePath[1:]
}
if !reflect.DeepEqual(got, []string{"a-base", "b-independent", "z-dependent"}) {
t.Fatalf("import schedule reverse=%t: %v", reverse, got)
}
if !reflect.DeepEqual(
[]string{contributions[0].source.ID, contributions[1].source.ID, contributions[2].source.ID},
[]string{"source-a-base", "source-b-independent", "source-z-dependent"},
) {
t.Fatal("scheduling mutated acquisition source records")
}
inheritanceText := "a-base: {value: ready}\nb-independent: {value: independent}\nz-dependent: {extends: a-base}\n"
if reverse {
inheritanceText = "z-dependent: {extends: a-base}\nb-independent: {value: independent}\na-base: {value: ready}\n"
}
scope := parseCompositionDocument(t, "inheritance", inheritanceText)
inheritance, diagnostics := analyzeInheritanceGraph(scope, Path{})
if len(diagnostics) != 0 {
t.Fatalf("analyze inheritance reverse=%t: %#v", reverse, diagnostics)
}
order, diagnostics := inheritance.schedule()
if len(diagnostics) != 0 {
t.Fatalf("schedule inheritance reverse=%t: %#v", reverse, diagnostics)
}
inheritanceNames := make([]string, len(order))
for index, node := range order {
inheritanceNames[index] = inheritance.nodes[node].name
}
if !reflect.DeepEqual(inheritanceNames, got) {
t.Fatalf("equivalent graph schedules differ reverse=%t: imports=%v inheritance=%v", reverse, got, inheritanceNames)
}
if want == nil {
want = got
} else if !reflect.DeepEqual(got, want) {
t.Fatalf("import graph construction changed schedule: got %v, want %v", got, want)
}
}
}

View File

@@ -0,0 +1,200 @@
package engine
import (
"container/heap"
"sort"
)
// dependencyKey is an immutable tuple used to order dependency-graph nodes.
// Callers provide unique keys and valid edge endpoints.
type dependencyKey []string
// dependencyEdge points from a prerequisite to the node that depends on it.
type dependencyEdge struct {
prerequisite int
dependent int
}
type dependencyReadyHeap struct {
nodes []int
keys []dependencyKey
}
func (h dependencyReadyHeap) Len() int { return len(h.nodes) }
func (h dependencyReadyHeap) Less(i, j int) bool {
return dependencyKeyLess(h.keys[h.nodes[i]], h.keys[h.nodes[j]])
}
func (h dependencyReadyHeap) Swap(i, j int) {
h.nodes[i], h.nodes[j] = h.nodes[j], h.nodes[i]
}
func (h *dependencyReadyHeap) Push(value any) {
h.nodes = append(h.nodes, value.(int))
}
func (h *dependencyReadyHeap) Pop() any {
last := len(h.nodes) - 1
node := h.nodes[last]
h.nodes = h.nodes[:last]
return node
}
// dependencyKeyLess compares tuple components bytewise, with a shorter tuple
// sorting first when all of its components match the corresponding prefix.
func dependencyKeyLess(left, right dependencyKey) bool {
for i := 0; i < len(left) && i < len(right); i++ {
if left[i] != right[i] {
return left[i] < right[i]
}
}
return len(left) < len(right)
}
// scheduleDependencies applies Kahn's algorithm, always selecting the
// smallest currently-ready key. Duplicate edges count only once. If scheduling
// stalls, an explicit depth-first traversal of the residual graph returns an
// actual directed cycle, rotated to its smallest key and closed by repeating
// its first node; blocked non-cycle dependents are excluded.
//
// Nodes are visited by precomputed key rank, which also makes each adjacency
// list deterministic without sorting edges. The work is O(E + V log V), with
// O(V + E) additional memory, apart from key copying and tuple comparisons.
func scheduleDependencies(keys []dependencyKey, edges []dependencyEdge) (order, cycle []int) {
ownedKeys := make([]dependencyKey, len(keys))
for i, key := range keys {
ownedKeys[i] = append(dependencyKey(nil), key...)
}
nodeOrder := make([]int, len(ownedKeys))
for i := range nodeOrder {
nodeOrder[i] = i
}
sort.Slice(nodeOrder, func(i, j int) bool {
return dependencyKeyLess(ownedKeys[nodeOrder[i]], ownedKeys[nodeOrder[j]])
})
incoming := make([][]int, len(ownedKeys))
for _, edge := range edges {
incoming[edge.dependent] = append(incoming[edge.dependent], edge.prerequisite)
}
outgoing := make([][]int, len(ownedKeys))
indegree := make([]int, len(ownedKeys))
for _, dependent := range nodeOrder {
seen := make(map[int]struct{}, len(incoming[dependent]))
for _, prerequisite := range incoming[dependent] {
if _, duplicate := seen[prerequisite]; duplicate {
continue
}
seen[prerequisite] = struct{}{}
indegree[dependent]++
// Dependents are visited by key rank, so each outgoing list is
// already ordered by key.
outgoing[prerequisite] = append(outgoing[prerequisite], dependent)
}
}
ready := dependencyReadyHeap{keys: ownedKeys}
for _, node := range nodeOrder {
if indegree[node] == 0 {
ready.nodes = append(ready.nodes, node)
}
}
heap.Init(&ready)
order = make([]int, 0, len(ownedKeys))
for ready.Len() != 0 {
node := heap.Pop(&ready).(int)
order = append(order, node)
for _, dependent := range outgoing[node] {
indegree[dependent]--
if indegree[dependent] == 0 {
heap.Push(&ready, dependent)
}
}
}
if len(order) == len(ownedKeys) {
return order, nil
}
return nil, dependencyCycle(ownedKeys, nodeOrder, outgoing, indegree)
}
type dependencyDFSFrame struct {
node int
next int
}
// dependencyCycle searches the residual graph iteratively to avoid recursion
// depth depending on input size.
func dependencyCycle(keys []dependencyKey, nodeOrder []int, outgoing [][]int, indegree []int) []int {
const (
unvisited = uint8(0)
active = uint8(1)
finished = uint8(2)
)
state := make([]uint8, len(keys))
position := make([]int, len(keys))
for i := range position {
position[i] = -1
}
path := make([]int, 0, len(keys))
frames := make([]dependencyDFSFrame, 0, len(keys))
for _, root := range nodeOrder {
if indegree[root] == 0 || state[root] != unvisited {
continue
}
state[root] = active
position[root] = len(path)
path = append(path, root)
frames = append(frames, dependencyDFSFrame{node: root})
for len(frames) != 0 {
frame := &frames[len(frames)-1]
if frame.next == len(outgoing[frame.node]) {
state[frame.node] = finished
position[frame.node] = -1
path = path[:len(path)-1]
frames = frames[:len(frames)-1]
continue
}
next := outgoing[frame.node][frame.next]
frame.next++
if indegree[next] == 0 {
continue
}
switch state[next] {
case unvisited:
state[next] = active
position[next] = len(path)
path = append(path, next)
frames = append(frames, dependencyDFSFrame{node: next})
case active:
return canonicalDependencyCycle(keys, path[position[next]:])
}
}
}
// A stalled Kahn traversal always leaves a cycle in the residual graph.
return nil
}
func canonicalDependencyCycle(keys []dependencyKey, path []int) []int {
start := 0
for i := 1; i < len(path); i++ {
if dependencyKeyLess(keys[path[i]], keys[path[start]]) {
start = i
}
}
cycle := make([]int, len(path)+1)
for i := range path {
cycle[i] = path[(start+i)%len(path)]
}
cycle[len(path)] = cycle[0]
return cycle
}

View File

@@ -0,0 +1,215 @@
package engine
import (
"reflect"
"testing"
)
func TestScheduleDependenciesOrdersReadyNodes(t *testing.T) {
tests := []struct {
name string
keys []string
edges [][2]string
want []string
}{
{
name: "independent branches",
keys: []string{"branch-c", "branch-a", "branch-b"},
want: []string{"branch-a", "branch-b", "branch-c"},
},
{
name: "unequal chains",
keys: []string{"chain-d", "chain-c", "chain-b", "chain-a"},
edges: [][2]string{
{"chain-a", "chain-b"},
{"chain-b", "chain-c"},
{"chain-c", "chain-d"},
},
want: []string{"chain-a", "chain-b", "chain-c", "chain-d"},
},
{
name: "multiple dependencies",
keys: []string{"application", "right", "base", "left"},
edges: [][2]string{
{"base", "left"}, {"base", "right"},
{"left", "application"}, {"right", "application"},
},
want: []string{"base", "left", "right", "application"},
},
{
name: "shared dependency at equal depths",
keys: []string{"sink", "right", "root", "left"},
edges: [][2]string{
{"root", "left"}, {"root", "right"},
{"left", "sink"}, {"right", "sink"},
},
want: []string{"root", "left", "right", "sink"},
},
{
name: "shared dependency at unequal depths",
keys: []string{"sink", "deep", "side", "middle", "root"},
edges: [][2]string{
{"root", "middle"}, {"middle", "deep"}, {"root", "side"},
{"deep", "sink"}, {"side", "sink"},
},
want: []string{"root", "middle", "deep", "side", "sink"},
},
{
name: "newly ready small key precedes older large key",
keys: []string{"z-ready", "a-new", "b-ready"},
edges: [][2]string{{"b-ready", "a-new"}},
want: []string{"b-ready", "a-new", "z-ready"},
},
{
name: "duplicate edges count once",
keys: []string{"dependent", "prerequisite"},
edges: [][2]string{
{"prerequisite", "dependent"},
{"prerequisite", "dependent"},
},
want: []string{"prerequisite", "dependent"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
order, cycle := scheduleDependencies(keys, edges)
if len(cycle) != 0 {
t.Fatalf("unexpected cycle: %v", dependencyNames(cycle, indexes))
}
if got := dependencyNames(order, indexes); !reflect.DeepEqual(got, test.want) {
t.Fatalf("schedule = %v, want %v", got, test.want)
}
})
}
}
func TestScheduleDependenciesUsesTupleKeys(t *testing.T) {
keys := []dependencyKey{
{"a", "z"},
{"a"},
{"a", "aa"},
{"A"},
}
order, cycle := scheduleDependencies(keys, nil)
if len(cycle) != 0 {
t.Fatalf("unexpected cycle: %v", cycle)
}
want := []int{3, 1, 2, 0}
if !reflect.DeepEqual(order, want) {
t.Fatalf("schedule = %v, want %v", order, want)
}
}
func TestScheduleDependenciesReportsActualCanonicalCycles(t *testing.T) {
tests := []struct {
name string
keys []string
edges [][2]string
want []string
}{
{
name: "self cycle",
keys: []string{"self"},
edges: [][2]string{{"self", "self"}},
want: []string{"self", "self"},
},
{
name: "multi-node cycle excludes blocked dependent",
keys: []string{"blocked", "cycle-b", "cycle-a"},
edges: [][2]string{
{"cycle-a", "cycle-b"},
{"cycle-b", "cycle-a"},
{"cycle-b", "blocked"},
},
want: []string{"cycle-a", "cycle-b", "cycle-a"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
order, cycle := scheduleDependencies(keys, edges)
if len(order) != 0 {
t.Fatalf("unexpected schedule: %v", dependencyNames(order, indexes))
}
if got := dependencyNames(cycle, indexes); !reflect.DeepEqual(got, test.want) {
t.Fatalf("cycle = %v, want %v", got, test.want)
}
})
}
}
func TestScheduleDependenciesIsStableAcrossInsertionPermutations(t *testing.T) {
edges := [][2]string{
{"base", "left"}, {"base", "right"},
{"left", "sink"}, {"right", "sink"},
}
permutations := [][]string{
{"sink", "right", "base", "left"},
{"left", "base", "sink", "right"},
{"right", "left", "base", "sink"},
}
for i, permutation := range permutations {
keys, permutedEdges, indexes := dependencyGraph(permutation, edges)
if i%2 != 0 {
reverseDependencyEdges(permutedEdges)
}
order, cycle := scheduleDependencies(keys, permutedEdges)
if len(cycle) != 0 {
t.Fatalf("permutation %d returned cycle %v", i, dependencyNames(cycle, indexes))
}
if got, want := dependencyNames(order, indexes), []string{"base", "left", "right", "sink"}; !reflect.DeepEqual(got, want) {
t.Fatalf("permutation %d schedule = %v, want %v", i, got, want)
}
}
cycleEdges := [][2]string{{"b", "a"}, {"a", "b"}, {"b", "blocked"}}
cyclePermutations := [][]string{{"blocked", "b", "a"}, {"a", "blocked", "b"}, {"b", "a", "blocked"}}
for i, permutation := range cyclePermutations {
keys, permutedEdges, indexes := dependencyGraph(permutation, cycleEdges)
if i%2 != 0 {
reverseDependencyEdges(permutedEdges)
}
order, cycle := scheduleDependencies(keys, permutedEdges)
if len(order) != 0 {
t.Fatalf("cycle permutation %d returned schedule %v", i, dependencyNames(order, indexes))
}
if got, want := dependencyNames(cycle, indexes), []string{"a", "b", "a"}; !reflect.DeepEqual(got, want) {
t.Fatalf("cycle permutation %d returned %v, want %v", i, got, want)
}
}
}
func dependencyGraph(nodeNames []string, namedEdges [][2]string) ([]dependencyKey, []dependencyEdge, map[int]string) {
keys := make([]dependencyKey, len(nodeNames))
indexes := make(map[string]int, len(nodeNames))
for i, name := range nodeNames {
keys[i] = dependencyKey{name}
indexes[name] = i
}
edges := make([]dependencyEdge, len(namedEdges))
for i, edge := range namedEdges {
edges[i] = dependencyEdge{prerequisite: indexes[edge[0]], dependent: indexes[edge[1]]}
}
byIndex := make(map[int]string, len(nodeNames))
for name, index := range indexes {
byIndex[index] = name
}
return keys, edges, byIndex
}
func dependencyNames(nodes []int, names map[int]string) []string {
result := make([]string, len(nodes))
for i, node := range nodes {
result[i] = names[node]
}
return result
}
func reverseDependencyEdges(edges []dependencyEdge) {
for i, j := 0, len(edges)-1; i < j; i, j = i+1, j-1 {
edges[i], edges[j] = edges[j], edges[i]
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
package engine
import (
"bytes"
"fmt"
"reflect"
"strings"
"testing"
)
func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T) {
t.Parallel()
type entry struct {
Extends string `yaml:"extends"`
Label string `yaml:"label"`
Token *Secret `yaml:"token" configkit:"secret"`
Dynamic string `yaml:"dynamic"`
Derived string `yaml:"derived"`
Height int `yaml:"height"`
}
type configuration struct {
Catalog struct {
Entries map[string]entry `yaml:"entries"`
} `yaml:"catalog"`
}
load := func(dynamic string) *testResult[configuration] {
t.Helper()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "entry.yml", `configkit:
imports:
- path: parent.yml
destination: [entries]
- path: child.yml
destination: [entries]
`)
writeImportFile(t, directory, "parent.yml", `parent:
label: lower
token: PARENT_TOKEN
height: 1
`)
writeImportFile(t, directory, "child.yml", fmt.Sprintf(`child:
extends: parent
label: child
dynamic: %s
height: 9
`, dynamic))
var defaultsCalls, resolveCalls, validateCalls int
secretPaths := make(map[string]bool)
lookupCalls := make(map[string]int)
request := loadRequest[configuration]{
main: loadInput{
source: Bytes("main", []byte("parent: {label: final, height: 4}\n")),
destination: Keys("catalog", "entries"),
},
underlays: []loadGroup{{inputs: []loadInput{{
source: File(mainPath), imports: true, destination: Keys("catalog"),
}}}},
definition: Definition[configuration]{
Defaults: func() configuration {
defaultsCalls++
var defaults configuration
defaults.Catalog.Entries = map[string]entry{"fallback": {Label: "default"}}
return defaults
},
Resolve: func(config *configuration, resolution *Resolution) error {
resolveCalls++
child := config.Catalog.Entries["child"]
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
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"))
return nil
},
Validate: func(config *configuration, validation *Validation) {
validateCalls++
parent := config.Catalog.Entries["parent"]
child := config.Catalog.Entries["child"]
if parent.Label != "final" || parent.Height != 4 || child.Label != "child" || child.Height != 9 || child.Derived != child.Label+":"+child.Dynamic+":9" || child.Token == nil || !child.Token.IsResolved() || child.Token.Value() != "parent-secret-canary" {
validation.Error(Keys("catalog", "entries", "child"), "final imported configuration was not available to validation")
}
},
Secret: func(path Path) bool {
secretPaths[path.String()] = true
return path.String() == "/catalog/entries/child/dynamic"
},
},
}
loaded, err := loadGroupedWithSecretLookup(request, testSecretBinding(), func(name string) (string, bool) {
lookupCalls[name]++
if name == "PARENT_TOKEN" {
return "parent-secret-canary", true
}
return "", false
})
if err != nil {
if loadError, ok := err.(*LoadError); ok {
t.Fatalf("relative import lifecycle diagnostics: %#v", loadError.Diagnostics())
}
t.Fatalf("relative import lifecycle: %v", err)
}
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || lookupCalls["PARENT_TOKEN"] != 1 {
t.Fatalf("lifecycle calls defaults=%d resolve=%d validate=%d lookups=%#v", defaultsCalls, resolveCalls, validateCalls, lookupCalls)
}
if !secretPaths["/catalog/entries/child/dynamic"] {
t.Fatalf("secret classifier did not see effective paths: %#v", secretPaths)
}
return testLoaded(loaded)
}
left := load("LEFT-DYNAMIC-CANARY")
child := left.Config.Catalog.Entries["child"]
if child.Extends != "" || child.Label != "child" || child.Height != 9 || child.Derived != "child:LEFT-DYNAMIC-CANARY:9" || child.Token == nil || child.Token.Value() != "parent-secret-canary" {
t.Fatalf("resolved imported child = %#v", child)
}
if left.Config.Catalog.Entries["parent"].Label != "final" || left.Config.Catalog.Entries["parent"].Height != 4 || left.Config.Catalog.Entries["fallback"].Label != "default" {
t.Fatalf("final parent/default shape = %#v", left.Config.Catalog.Entries)
}
childTokenPath := Keys("catalog", "entries", "child", "token")
childToken, found := left.Inspection.Explain(childTokenPath)
childTokenInfo, tokenInfoFound := left.Inspection.Metadata().Lookup(childTokenPath)
if !found || !tokenInfoFound || !childToken.Effective.Redacted || strings.Contains(childToken.Effective.YAML, "parent-secret-canary") || len(childTokenInfo.Origin.Inheritance) != 1 {
t.Fatalf("inherited field explanation = %#v, %v", childToken, found)
}
var explanation bytes.Buffer
if err := left.Inspection.WriteExplanation(&explanation, childTokenPath); err != nil || strings.Contains(explanation.String(), "parent-secret-canary") || strings.Contains(explanation.String(), "LEFT-DYNAMIC-CANARY") {
t.Fatalf("safe explanation = %q, %v", explanation.String(), err)
}
labelHistory := left.Inspection.Metadata().History(Keys("catalog", "entries", "parent", "label"))
if len(labelHistory) < 2 {
t.Fatalf("parent label history omitted replaced contribution: %#v", labelHistory)
}
derived, found := left.Inspection.Explain(Keys("catalog", "entries", "child", "derived"))
if !found || !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "LEFT-DYNAMIC-CANARY") || len(derived.History) == 0 || derived.History[len(derived.History)-1].Origin.Operation != ChangeNormalization {
t.Fatalf("recorded derived field explanation = %#v, %v", derived, found)
}
right := load("RIGHT-DYNAMIC-CANARY")
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil {
t.Fatal(err)
}
seen := make(map[string]bool)
for _, difference := range differences {
path := difference.Path.String()
seen[path] = true
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged || strings.Contains(difference.Left.YAML+difference.Right.YAML, "DYNAMIC-CANARY") || strings.Contains(difference.Left.YAML+difference.Right.YAML, "parent-secret-canary") {
t.Errorf("sensitive difference at %s = %#v", path, difference)
}
}
for _, path := range []string{"/catalog/entries/child/dynamic", "/catalog/entries/child/derived"} {
if !seen[path] {
t.Errorf("missing inherited/derived difference at %s: %#v", path, differences)
}
}
}
func TestRelativeImportMatchesWrappedPlacementAndSafeDiff(t *testing.T) {
t.Parallel()
load := func(relativeImport bool, value string) *testResult[map[string]any] {
t.Helper()
input := Input{Source: NoSource()}
options := LayeredOptions[map[string]any]{}
if relativeImport {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: fragment.yml
destination: [application]
`)
writeImportFile(t, directory, "fragment.yml", "field: "+value+"\n")
options.Underlays = []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}}
} else {
options.Underlays = []Underlay{{Inputs: []Input{{Source: Bytes("wrapped", []byte("application: {field: "+value+"}\n"))}}}}
}
loaded, err := LoadLayered(input, testSecretBinding(), options)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
return testLoaded(loaded)
}
relativeBefore := load(true, "before")
relativeAfter := load(true, "after")
wrappedBefore := load(false, "before")
wrappedAfter := load(false, "after")
for _, pair := range [][2]*testResult[map[string]any]{{relativeBefore, wrappedBefore}, {relativeAfter, wrappedAfter}} {
if !reflect.DeepEqual(pair[0].Config, pair[1].Config) {
t.Fatalf("relative and wrapped configurations differ: %#v vs %#v", pair[0].Config, pair[1].Config)
}
for _, result := range pair {
if info, ok := result.Metadata().Lookup(Keys("application", "field")); !ok || !info.Explicit {
t.Fatalf("effective field presence = %#v, %v", info, ok)
}
}
}
relativeDiff, err := Diff(relativeBefore.Inspection, relativeAfter.Inspection)
if err != nil {
t.Fatal(err)
}
wrappedDiff, err := Diff(wrappedBefore.Inspection, wrappedAfter.Inspection)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(relativeDiff, wrappedDiff) {
t.Fatalf("relative safe diff = %#v, wrapped safe diff = %#v", relativeDiff, wrappedDiff)
}
}

View File

@@ -0,0 +1,573 @@
package engine
import (
"path/filepath"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type importDestinationConfig struct {
Mounted map[string]string `yaml:"mounted"`
Collections map[string]map[string]string `yaml:"collections"`
}
func TestLayeredHostImportsInheritInputDestinationTransitively(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: first.yml
- path: second.yml
root: main
`)
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
imports:
- path: nested/third.yml
first: one
`)
thirdPath := writeImportFile(t, directory, "nested/third.yml", "nested: three\n")
secondPath := writeImportFile(t, directory, "second.yml", "second: two\n")
loaded, err := LoadLayered(
Input{Source: File(mainPath), Imports: true, Destination: Keys("mounted")},
testSecretBinding(),
LayeredOptions[importDestinationConfig]{},
)
if err != nil {
if loadError, ok := err.(*LoadError); ok {
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
}
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
want := map[string]string{"root": "main", "first": "one", "nested": "three", "second": "two"}
if len(result.Config.Mounted) != len(want) {
t.Fatalf("mounted values = %#v", result.Config.Mounted)
}
for key, value := range want {
if result.Config.Mounted[key] != value {
t.Errorf("mounted[%q] = %q, want %q", key, result.Config.Mounted[key], value)
}
}
if _, ok := result.Metadata().Lookup(Keys("mounted", "configkit")); ok {
t.Fatal("source-root import directive leaked into application content")
}
sources := result.Sources()
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
if len(sources) != len(wantPaths) {
t.Fatalf("sources = %#v", sources)
}
for index, source := range sources {
if source.Destination.String() != "/mounted" {
t.Errorf("source %d destination = %q", index, source.Destination.String())
}
if source.Path != wantPaths[index] {
t.Errorf("source %d path = %q, want %q", index, source.Path, wantPaths[index])
}
if index > 0 && source.ImportedFrom == nil {
t.Errorf("imported source %d has no physical import location", index)
}
}
}
func TestImportTraversalUsesPerDeclarationDestinations(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: parent.yml
- path: sibling.yml
- glob: fragments/*.yml
`)
parentPath := writeImportFile(t, directory, "parent.yml", `configkit:
imports:
- path: nested/child.yml
parent: true
`)
childPath := writeImportFile(t, directory, "nested/child.yml", "child: true\n")
siblingPath := writeImportFile(t, directory, "sibling.yml", "sibling: true\n")
alphaPath := writeImportFile(t, directory, "fragments/a.yml", "alpha: true\n")
betaPath := writeImportFile(t, directory, "fragments/b.yml", "beta: true\n")
main := parseImportMain(t, mainPath)
_, declarations, diagnostics := splitImportDirectives(main.document)
if len(diagnostics) != 0 {
t.Fatalf("main directive diagnostics = %#v", diagnostics)
}
declarations[0].destination = Keys("parent")
declarations[1].destination = Keys("sibling")
declarations[2].destination = Keys("collection")
access, err := openHostAccess(directory)
if err != nil {
t.Fatal(err)
}
defer func() { _ = access.close() }()
state := importTraversal{
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
}
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
t.Fatalf("walk diagnostics = %#v", diagnostics)
}
wantSources := []struct {
path string
destination string
}{
{path: parentPath, destination: "/entry/parent"},
{path: childPath, destination: "/entry/parent"},
{path: siblingPath, destination: "/entry/sibling"},
{path: alphaPath, destination: "/entry/collection"},
{path: betaPath, destination: "/entry/collection"},
}
if len(state.sources) != len(wantSources) {
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
}
for index, want := range wantSources {
got := state.sources[index]
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)
}
}
documents := make([]*document.Node, 0, len(state.scope.contributions))
for _, contribution := range state.scope.contributions {
documents = append(documents, contribution.document)
}
composed, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 {
t.Fatalf("composition diagnostics = %#v", diagnostics)
}
for _, path := range []Path{
Keys("entry", "parent", "parent"), Keys("entry", "parent", "child"),
Keys("entry", "sibling", "sibling"), Keys("entry", "collection", "alpha"),
Keys("entry", "collection", "beta"),
} {
if _, present := documentAtPath(composed.root, path); !present {
t.Errorf("no composed value at %q", path.String())
}
}
}
func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
t.Parallel()
t.Run("import context failure", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
input, diagnostics := assembleBaseInputTracked(
File(mainPath),
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
Keys("mounted"), &importAccounting{}, nil, nil,
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
t.Fatalf("context diagnostics = %#v", diagnostics)
}
if len(input.sources) != 1 || input.sources[0].Destination.String() != "/mounted" {
t.Fatalf("entry source fallback = %#v", input.sources)
}
})
t.Run("imported parse failure", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
writeImportFile(t, directory, "broken.yml", "broken: [\n")
input, diagnostics := assembleBaseInputTracked(
File(mainPath), Options[map[string]any]{Imports: true},
Keys("mounted"), &importAccounting{}, nil, nil,
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
if len(input.sources) != 2 {
t.Fatalf("parse-failure sources = %#v", input.sources)
}
for index, source := range input.sources {
if source.Destination.String() != "/mounted" {
t.Errorf("source %d destination = %q, want /mounted", index, source.Destination.String())
}
}
})
}
func TestRelativeImportDestinationsKeepStrictCompositionBoundaries(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
main string
files map[string]string
want map[string]any
conflict bool
wantPath string
}{
{
name: "disjoint peers at one destination",
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
files: map[string]string{"left.yml": "left: one\n", "right.yml": "right: two\n"},
want: map[string]any{"/application/left": "one", "/application/right": "two"},
},
{
name: "different destinations",
main: "configkit:\n imports:\n - path: left.yml\n destination: [left]\n - path: right.yml\n destination: [right]\n",
files: map[string]string{"left.yml": "value: shared\n", "right.yml": "value: shared\n"},
want: map[string]any{"/left/value": "shared", "/right/value": "shared"},
},
{
name: "ancestor and descendant overlap",
main: "configkit:\n imports:\n - path: parent.yml\n destination: [application]\n - path: child.yml\n destination: [application, branch]\n",
files: map[string]string{"parent.yml": "branch: {base: one}\n", "child.yml": "leaf: two\n"},
want: map[string]any{"/application/branch/base": "one", "/application/branch/leaf": "two"},
},
{
name: "collision with explicitly wrapped content",
main: "configkit: {imports: [{path: fragment.yml, destination: [application]}]}\napplication: {value: root}\n",
files: map[string]string{"fragment.yml": "value: imported\n"},
conflict: true,
wantPath: "/application/value",
},
{
name: "equal peer values conflict",
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
files: map[string]string{"left.yml": "value: same\n", "right.yml": "value: same\n"},
conflict: true,
wantPath: "/application/value",
},
{
name: "unequal peer values conflict",
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
files: map[string]string{"left.yml": "value: left\n", "right.yml": "value: right\n"},
conflict: true,
wantPath: "/application/value",
},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", test.main)
for name, body := range test.files {
writeImportFile(t, directory, name, body)
}
loaded, err := LoadLayered(
Input{Source: File(mainPath), Imports: true}, testSecretBinding(),
LayeredOptions[map[string]any]{},
)
if test.conflict {
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
t.Fatalf("relative import composition = %#v, %v", loaded, err)
}
loadError := err.(*LoadError)
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.wantPath {
t.Fatalf("conflict path = %q, want %q", diagnostics[0].Path.String(), test.wantPath)
}
return
}
if err != nil {
t.Fatalf("relative import composition: %v", err)
}
configuration := testLoaded(loaded).Config
for path, want := range test.want {
parsed, err := ParsePath(path)
if err != nil {
t.Fatal(err)
}
keys := make([]string, len(parsed.segments))
for index, segment := range parsed.segments {
keys[index] = segment.key
}
if got := layeredMapValue(t, configuration, keys...); got != want {
t.Errorf("value at %s = %#v, want %#v", path, got, want)
}
}
})
}
t.Run("upper tier cannot repair a lower import conflict", func(t *testing.T) {
directory := t.TempDir()
lowerPath := writeImportFile(t, directory, "lower.yml", `configkit:
imports:
- path: left.yml
destination: [application]
- path: right.yml
destination: [application]
`)
writeImportFile(t, directory, "left.yml", "value: left\n")
writeImportFile(t, directory, "right.yml", "value: right\n")
loaded, err := LoadLayered(
Input{Source: Bytes("upper", []byte("application: {value: upper}\n"))},
testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{Source: File(lowerPath), Imports: true}}}}},
)
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
t.Fatalf("upper tier repaired a lower conflict: %#v, %v", loaded, err)
}
})
}
func TestRelativeImportDestinationsUseEffectiveVersionOwnership(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
rulePath Path
fragment string
}{
{name: "direct claim", rulePath: Keys("collection", "group", "nested", "version"), fragment: "version: v2\n"},
{name: "ancestor claim", rulePath: Keys("collection", "group", "nested", "schema", "version"), fragment: "schema: null\n"},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: parent.yml
destination: [group]
`)
writeImportFile(t, directory, "parent.yml", `configkit:
imports:
- path: fragment.yml
destination: [nested]
`)
writeImportFile(t, directory, "fragment.yml", test.fragment)
_, err := LoadLayered(
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
testSecretBinding(),
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
Path: test.rulePath, Check: func(Version, *Validation) error { return nil },
}}},
)
if groupedErrorCode(t, err) != CodeImportVersion {
t.Fatalf("effective imported claim error = %v", err)
}
loadError := err.(*LoadError)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != test.rulePath.String() || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-3" {
t.Fatalf("effective version diagnostic = %#v", diagnostic)
}
sources := loadError.Sources()
wantDestinations := []string{"/collection", "/collection/group", "/collection/group/nested"}
if len(sources) != len(wantDestinations) {
t.Fatalf("version failure sources = %#v", sources)
}
for index, source := range sources {
if source.Destination.String() != wantDestinations[index] {
t.Errorf("source %d destination = %q, want %q", index, source.Destination.String(), wantDestinations[index])
}
}
})
}
}
func TestRelativeImportDestinationsKeepMainVersionCheckScoped(t *testing.T) {
t.Parallel()
type config struct {
Collection struct {
SchemaVersion string `yaml:"schema_version"`
Group map[string]any `yaml:"group"`
} `yaml:"collection"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: parent.yml
destination: [group]
schema_version: 6
`)
writeImportFile(t, directory, "parent.yml", `configkit:
imports:
- path: fragment.yml
destination: [nested]
`)
writeImportFile(t, directory, "fragment.yml", "version: 7\n")
checks := 0
loaded, err := LoadLayered(
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
testSecretBinding(),
LayeredOptions[config]{Definition: Definition[config]{Version: &VersionRule{
Path: Keys("collection", "schema_version"),
Check: func(version Version, _ *Validation) error {
checks++
if !version.Present || version.Text != "6" {
t.Errorf("main-owned version = %#v", version)
}
return nil
},
}}},
)
if err != nil {
t.Fatalf("ordinary nested imported version was rejected: %v", err)
}
result := testLoaded(loaded)
if checks != 1 || result.Config.Collection.SchemaVersion != "6" {
t.Fatalf("main version callback/value = %d/%q", checks, result.Config.Collection.SchemaVersion)
}
nested, ok := result.Config.Collection.Group["nested"].(map[string]any)
if !ok {
t.Fatalf("nested imported payload = %#v", result.Config.Collection.Group["nested"])
}
if got, ok := nested["version"].(int64); !ok || got != 7 {
t.Fatalf("ordinary nested version = %#v, want integer payload 7", nested["version"])
}
}
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
t.Parallel()
native := fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: first.yml\nroot: main\n")},
"first.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: nested.yml}]}\nfirst: imported\n")},
"nested.yml": &fstest.MapFile{Data: []byte("nested: imported\n")},
}
namespace := newNativeNamespace("bundle", native)
loaded, err := LoadLayered(
Input{Source: filesystemFile(namespace, "main.yml"), Imports: true, Destination: Keys("mounted")},
testSecretBinding(),
LayeredOptions[importDestinationConfig]{},
)
if err != nil {
if loadError, ok := err.(*LoadError); ok {
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
}
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if result.Config.Mounted["root"] != "main" || result.Config.Mounted["first"] != "imported" || result.Config.Mounted["nested"] != "imported" {
t.Fatalf("mounted native filesystem values = %#v", result.Config.Mounted)
}
sources := result.Sources()
if len(sources) != 3 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" || sources[2].Destination.String() != "/mounted" {
t.Fatalf("native source destinations = %#v", sources)
}
}
func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: optional.yml
`)
loaded, err := LoadLayered(
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
testSecretBinding(),
LayeredOptions[importDestinationConfig]{},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
info, ok := result.Metadata().Lookup(Keys("collections", "empty"))
if !ok || !info.Explicit {
t.Fatalf("import-only body destination = %#v, %v", info, ok)
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Destination.String() != "/collections/empty" {
t.Fatalf("sources = %#v", sources)
}
}
func TestLayeredImportedVersionClaimsUseEffectiveDestination(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
rulePath Path
destination Path
wantCode Code
}{
{name: "protected effective path", rulePath: Keys("mounted", "version"), destination: Keys("mounted"), wantCode: CodeImportVersion},
{name: "ordinary nested domain version", rulePath: Keys("version"), destination: Keys("collections", "entry")},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
writeImportFile(t, directory, "fragment.yml", "version: v2\n")
rule := &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}
loaded, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[importDestinationConfig]{
Definition: Definition[importDestinationConfig]{Version: rule},
Underlays: []Underlay{{Inputs: []Input{{
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: test.destination,
}}}},
},
)
if test.wantCode != "" {
if err == nil {
t.Fatal("protected imported version claim was accepted")
}
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.wantCode {
t.Fatalf("load error = %#v", err)
}
if test.name == "protected effective path" {
sources := loadError.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" {
t.Fatalf("error source destinations = %#v", sources)
}
}
return
}
if err != nil {
t.Fatalf("ordinary nested version was rejected: %v", err)
}
result := testLoaded(loaded)
if got := result.Config.Collections["entry"]["version"]; got != "v2" {
t.Fatalf("nested version = %q", got)
}
})
}
}
func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
t.Parallel()
directory := t.TempDir()
writeImportFile(t, directory, "shared.yml", "value: shared\n")
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml, destination: [nested]}]}\n")
_, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[importDestinationConfig]{Underlays: []Underlay{{Inputs: []Input{
{Source: File(importerPath), Imports: true, Destination: Keys("imported")},
{Source: File(filepath.Join(directory, "shared.yml")), Destination: Keys("direct")},
}}}},
)
if err == nil {
t.Fatal("the same physical source was accepted at two destinations in one group")
}
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
t.Fatalf("load error = %#v", err)
}
sources := loadError.Sources()
if len(sources) != 3 || sources[1].Destination.String() != "/imported/nested" || sources[2].Destination.String() != "/direct" {
t.Fatalf("repeated import sources = %#v", sources)
}
t.Run("placement does not break a physical cycle", func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: parent.yml, destination: [one]}]}\n")
writeImportFile(t, directory, "parent.yml", "configkit: {imports: [{path: main.yml, destination: [two]}]}\n")
_, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, testSecretBinding(), LayeredOptions[map[string]any]{})
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportCycle {
t.Fatalf("cycle load error = %#v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
t.Fatalf("cycle primary location = %#v", diagnostic.Primary)
}
if sources := loadError.Sources(); len(sources) != 2 || sources[1].Destination.String() != "/one" {
t.Fatalf("cycle sources = %#v", sources)
}
})
}

View File

@@ -0,0 +1,500 @@
package engine
import (
"bytes"
"fmt"
"reflect"
"strings"
"testing"
"testing/fstest"
)
func TestLayeredDestinationInheritanceUsesFinalSiblingScopes(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered(
Input{Source: Bytes("main", []byte("parent:\n label: final\n settings:\n primary: {height: 4}\ncancelled:\n extends: null\n own: retained\n")), Destination: Keys("catalog", "entries")},
testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
{Source: Bytes("parent", []byte("parent:\n label: lower\n settings:\n primary: {width: '10'}\n secondary: {extends: primary}\ncancelled:\n extends: parent\n own: retained\n")), Destination: Keys("catalog", "entries")},
{Source: Bytes("child", []byte("child:\n extends: parent\n settings:\n primary: {width: '20'}\n secondary: {extends: primary}\n")), Destination: Keys("catalog", "entries")},
}}}},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
entries := layeredMap(t, layeredMap(t, result.Config, "catalog"), "entries")
parent := layeredMap(t, entries, "parent")
child := layeredMap(t, entries, "child")
parentSettings := layeredMap(t, parent, "settings")
childSettings := layeredMap(t, child, "settings")
if parent["label"] != "final" || child["label"] != "final" {
t.Fatalf("higher-tier parent was not inherited after precedence: parent=%#v child=%#v", parent, child)
}
if layeredMapValue(t, parentSettings, "secondary", "width") != "10" ||
fmt.Sprint(layeredMapValue(t, parentSettings, "secondary", "height")) != "4" ||
layeredMapValue(t, childSettings, "secondary", "width") != "20" ||
fmt.Sprint(layeredMapValue(t, childSettings, "secondary", "height")) != "4" {
t.Fatalf("nested sibling scopes were not resolved at their destinations: parent=%#v child=%#v", parentSettings, childSettings)
}
cancelled := layeredMap(t, entries, "cancelled")
if len(cancelled) != 1 || cancelled["own"] != "retained" {
t.Fatalf("higher-tier cancellation did not suppress inheritance: %#v", cancelled)
}
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", inheritanceKey)); ok {
t.Fatal("effective child retained its extends directive")
}
}
func TestLayeredDestinationInheritanceDoesNotSearchOtherCollections(t *testing.T) {
t.Parallel()
_, err := LoadLayered(
Input{Source: Bytes("main", []byte("parent: {value: foreign}\n")), Destination: Keys("catalog", "other")},
testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("child", []byte("child: {extends: parent}\n")),
Destination: Keys("catalog", "entries"),
}}}}},
)
if got := groupedErrorCode(t, err); got != CodeInheritanceMissing {
t.Fatalf("cross-collection parent lookup error = %s, want %s", got, CodeInheritanceMissing)
}
}
func TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
body string
rulePath Path
}{
{name: "inherited protected value", body: "parent: {version: v2}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "version")},
{name: "inherited protected ancestor", body: "parent: {schema: null}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "schema", "version")},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
_, err := LoadLayered(
Input{Source: NoSource()}, testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}},
Underlays: []Underlay{{Inputs: []Input{{Source: Bytes("underlay", []byte(test.body)), Destination: Keys("catalog", "entries")}}}},
},
)
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
t.Fatalf("inheritance version protection code = %s", got)
}
})
}
t.Run("inheritance consumes a protected directive", func(t *testing.T) {
_, err := LoadLayered(
Input{Source: Bytes("main", []byte("parent: {value: inherited}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries")},
testSecretBinding(),
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
Path: Keys("catalog", "entries", "child", "extends"),
Check: func(version Version, _ *Validation) error {
if !version.Present || version.Text != "parent" {
t.Errorf("mounted protected directive value = %#v", version)
}
return nil
},
}}},
)
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
t.Fatalf("consumed directive protection code = %s", got)
}
})
t.Run("inherited mapping ancestor without a version claim", func(t *testing.T) {
loaded, err := LoadLayered(
Input{Source: NoSource()}, testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "entries", "child", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("underlay", []byte("parent: {schema: {name: ordinary}}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries"),
}}}},
},
)
if err != nil {
t.Fatalf("nonclaiming inherited mapping ancestor was rejected: %v", err)
}
if got := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "schema", "name"); got != "ordinary" {
t.Fatalf("inherited non-version value = %#v", got)
}
})
for _, test := range []struct {
name string
body string
}{
{name: "overlay protected value", body: "catalog: {schema: {version: v2}}\n"},
{name: "overlay protected ancestor", body: "catalog: {schema: null}\n"},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("overlay", fstest.MapFS{"overlays/test.yml": &fstest.MapFile{Data: []byte(test.body)}})
_, err := LoadLayered(
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(version Version, _ *Validation) error {
if !version.Present || version.Text != "v1" {
t.Errorf("mounted main version = %#v", version)
}
return nil
}}},
Overlay: "test", OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
},
)
if got := groupedErrorCode(t, err); got != CodeOverlayVersion {
t.Fatalf("overlay version protection code = %s", got)
}
})
}
for _, test := range []struct {
name string
path Path
value any
}{
{name: "override protected value", path: Keys("catalog", "schema", "version"), value: "v2"},
{name: "override protected ancestor", path: Keys("catalog", "schema"), value: map[string]any{"version": "v2"}},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
_, err := LoadLayered(
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
Overrides: []Override{{Path: test.path, Value: test.value, Flag: "--config"}},
},
)
if got := groupedErrorCode(t, err); got != CodeOverrideVersion {
t.Fatalf("override version protection code = %s", got)
}
})
}
t.Run("unrelated inheritance under the protected mapping ancestor", func(t *testing.T) {
loaded, err := LoadLayered(
Input{Source: Bytes("main", []byte("schema: {version: v1}\nentries:\n parent: {value: inherited}\n child: {extends: parent}\n")), Destination: Keys("catalog")},
testSecretBinding(),
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}}},
)
if err != nil {
t.Fatalf("unrelated mounted inheritance was rejected: %v", err)
}
child := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "value")
if child != "inherited" {
t.Fatalf("unrelated inheritance value = %#v", child)
}
})
}
func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
t.Parallel()
definition := Definition[map[string]any]{
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
sequenceRegistrations: []sequenceRegistration{{path: Keys("catalog", "entries"), identityField: "id"}},
}
item := func(label string, value map[string]any) Input {
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}
}
options := func(inputs ...Input) LayeredOptions[map[string]any] {
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
}
main := item("main", map[string]any{"id": "main", "schema": map[string]any{"version": "v1"}})
t.Run("same assigned main path is protected", func(t *testing.T) {
_, err := LoadLayered(main, testSecretBinding(), options(item("underlay", map[string]any{"id": "underlay", "schema": nil})))
if got := groupedErrorCode(t, err); got != CodeUnderlayVersion {
t.Fatalf("protected item claim code = %s, want %s", got, CodeUnderlayVersion)
}
})
t.Run("appended peer does not claim main path", func(t *testing.T) {
loaded, err := LoadLayered(main, testSecretBinding(), options(
item("first", map[string]any{"id": "first", "schema": map[string]any{"other": "value"}}),
item("peer", map[string]any{"id": "peer", "schema": nil}),
))
if err != nil {
t.Fatalf("unrelated appended peer rejected: %v", err)
}
entries := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries").([]any)
if len(entries) != 1 || entries[0].(map[string]any)["id"] != "main" {
t.Fatalf("main item projection = %#v", entries)
}
})
}
type destinationDecodeProbe string
var destinationDecodeProbeValues []string
func (value *destinationDecodeProbe) UnmarshalText(text []byte) error {
destinationDecodeProbeValues = append(destinationDecodeProbeValues, string(text))
*value = destinationDecodeProbe(text)
return nil
}
func (value destinationDecodeProbe) MarshalText() ([]byte, error) {
return []byte(value), nil
}
type destinationLifecycleConfig struct {
Defaulted string `yaml:"defaulted"`
Tenant struct {
Tagged string `yaml:"tagged" configkit:"secret"`
Dynamic string `yaml:"dynamic"`
Credential Secret `yaml:"credential"`
Derived string `yaml:"derived"`
Decoded destinationDecodeProbe `yaml:"decoded"`
} `yaml:"tenant"`
}
func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testing.T) {
const (
oldTagged = "SUPERSEDED-TAGGED-CANARY"
newTagged = "CURRENT-TAGGED-CANARY"
oldDynamic = "SUPERSEDED-DYNAMIC-CANARY"
newDynamic = "CURRENT-DYNAMIC-CANARY"
credential = "RESOLVED-CREDENTIAL-CANARY"
secretName = "DESTINATION_SECRET_NAME"
decodeValue = "decoded-once"
)
type calls struct{ defaults, resolve, lookup, validate int }
newLoad := func(mounted bool, observed *calls) (*testResult[destinationLifecycleConfig], error) {
mainBody := "tagged: " + newTagged + "\ndynamic: " + newDynamic + "\ncredential: " + secretName + "\ndecoded: " + decodeValue + "\n"
underlayBody := "tagged: " + oldTagged + "\ndynamic: " + oldDynamic + "\ncredential: OLD_DESTINATION_SECRET_NAME\ndecoded: old\n"
if !mounted {
mainBody = "tenant:\n" + indentDestinationBody(mainBody)
underlayBody = "tenant:\n" + indentDestinationBody(underlayBody)
}
main := loadInput{source: Bytes("main", []byte(mainBody))}
underlay := loadInput{source: Bytes("underlay", []byte(underlayBody))}
if mounted {
main.destination = Keys("tenant")
underlay.destination = Keys("tenant")
}
definition := Definition[destinationLifecycleConfig]{
Defaults: func() destinationLifecycleConfig {
observed.defaults++
return destinationLifecycleConfig{Defaulted: "from-default"}
},
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
observed.resolve++
configuration.Tenant.Derived = configuration.Tenant.Dynamic
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic"))
return nil
},
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
observed.validate++
if configuration.Tenant.Decoded != destinationDecodeProbe(decodeValue) || configuration.Tenant.Dynamic != newDynamic {
t.Errorf("validation saw a non-final mounted configuration: %#v", configuration.Tenant)
}
},
Secret: func(path Path) bool { return path.String() == "/tenant/dynamic" },
}
loaded, err := loadGroupedWithSecretLookup(loadRequest[destinationLifecycleConfig]{
main: main, underlays: []loadGroup{{inputs: []loadInput{underlay}}}, definition: definition,
}, testSecretBinding(), func(name string) (string, bool) {
observed.lookup++
if name != secretName {
return "", false
}
return credential, true
})
return testLoaded(loaded), err
}
var observed calls
destinationDecodeProbeValues = nil
mounted, err := newLoad(true, &observed)
if err != nil {
if loadError, ok := err.(*LoadError); ok {
t.Fatalf("mounted load: %#v", loadError.Diagnostics())
}
t.Fatalf("mounted load: %v", err)
}
if observed != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
t.Fatalf("mounted lifecycle counts = %#v", observed)
}
sawFinalDecode := false
for _, value := range destinationDecodeProbeValues {
if value == decodeValue {
sawFinalDecode = true
} else if value != "" {
t.Fatalf("decoder observed a non-final mounted value %q: %#v", value, destinationDecodeProbeValues)
}
}
if !sawFinalDecode {
t.Fatal("final mounted value was not decoded")
}
if mounted.Config.Defaulted != "from-default" || mounted.Config.Tenant.Tagged != newTagged || mounted.Config.Tenant.Dynamic != newDynamic ||
mounted.Config.Tenant.Derived != newDynamic || mounted.Config.Tenant.Decoded != destinationDecodeProbe(decodeValue) ||
mounted.Config.Tenant.Credential.Value() != credential {
t.Fatalf("mounted final configuration = %#v", mounted.Config)
}
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
if !mounted.Inspection.secrets[path.String()] {
t.Errorf("effective sensitivity missing at %s: %#v", path, mounted.Inspection.secrets)
}
}
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
explanation, ok := mounted.Explain(path)
if !ok || !explanation.Effective.Redacted {
t.Errorf("effective explanation at %s = %#v, %v", path, explanation, ok)
continue
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML+change.After.YAML, oldTagged) ||
strings.Contains(change.Before.YAML+change.After.YAML, newTagged) ||
strings.Contains(change.Before.YAML+change.After.YAML, oldDynamic) ||
strings.Contains(change.Before.YAML+change.After.YAML, newDynamic) {
t.Errorf("classified history leaked a canary at %s: %#v", path, change)
}
}
var rendered bytes.Buffer
if err := mounted.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), "CANARY") {
t.Errorf("safe explanation at %s = %q, %v", path, rendered.String(), err)
}
}
var effective bytes.Buffer
if err := mounted.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
for _, canary := range []string{oldTagged, newTagged, oldDynamic, newDynamic, credential} {
if strings.Contains(effective.String(), canary) {
t.Fatalf("effective rendering exposed %q: %s", canary, effective.String())
}
}
if serializedConfig := fmt.Sprintf("%#v", mounted.Config); strings.Contains(serializedConfig, credential) {
t.Fatalf("configuration formatting exposed the resolved credential: %s", serializedConfig)
}
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic")} {
if history := fmt.Sprintf("%#v", mounted.Metadata().History(path)); strings.Contains(history, "CANARY") {
t.Fatalf("metadata history at %s exposed a canary: %s", path, history)
}
}
var authoredCalls calls
destinationDecodeProbeValues = nil
authored, err := newLoad(false, &authoredCalls)
if err != nil {
t.Fatalf("explicitly wrapped load: %v", err)
}
if authoredCalls != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
t.Fatalf("authored lifecycle counts = %#v", authoredCalls)
}
sawFinalDecode = false
for _, value := range destinationDecodeProbeValues {
if value == decodeValue {
sawFinalDecode = true
} else if value != "" {
t.Fatalf("authored decoder observed a non-final value %q: %#v", value, destinationDecodeProbeValues)
}
}
if !sawFinalDecode {
t.Fatal("final authored value was not decoded")
}
if !reflect.DeepEqual(mounted.Config, authored.Config) {
t.Fatalf("mounted config %#v differs from explicitly wrapped config %#v", mounted.Config, authored.Config)
}
differences, err := Diff(mounted.Inspection, authored.Inspection)
if err != nil || len(differences) != 0 {
t.Fatalf("mounted versus authored diff = %#v, %v", differences, err)
}
}
func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
t.Parallel()
type config struct {
Tenant struct {
Dynamic string `yaml:"dynamic"`
Derived string `yaml:"derived"`
} `yaml:"tenant"`
}
load := func(value string, classify bool) *testResult[config] {
t.Helper()
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
main: loadInput{source: Bytes("input", []byte("dynamic: "+value+"\n")), destination: Keys("tenant")},
definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
configuration.Tenant.Derived = configuration.Tenant.Dynamic
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic"))
return nil
},
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },
},
}, testSecretBinding(), func(string) (string, bool) { return "", false })
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
return testLoaded(loaded)
}
left := load("LEFT-DIFF-CANARY", true)
right := load("RIGHT-DIFF-CANARY", false)
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil {
t.Fatal(err)
}
if len(differences) != 2 {
t.Fatalf("differences = %#v", differences)
}
seen := make(map[string]bool)
for _, difference := range differences {
path := difference.Path.String()
seen[path] = true
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged ||
strings.Contains(difference.Left.YAML+difference.Right.YAML, "DIFF-CANARY") {
t.Errorf("bilaterally classified difference at %s = %#v", path, difference)
}
}
for _, path := range []string{"/tenant/dynamic", "/tenant/derived"} {
if !seen[path] || !left.Inspection.secrets[path] || right.Inspection.secrets[path] {
t.Errorf("classification/difference at %s: seen=%v left=%v right=%v", path, seen[path], left.Inspection.secrets[path], right.Inspection.secrets[path])
}
}
}
func TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks(t *testing.T) {
t.Parallel()
type config struct {
Tenant struct {
Count int `yaml:"count"`
} `yaml:"tenant"`
}
calls := struct{ defaults, resolve, lookup, validate int }{}
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
main: loadInput{source: Bytes("invalid", []byte("count: not-an-integer\n")), destination: Keys("tenant")},
definition: Definition[config]{
Defaults: func() config { calls.defaults++; return config{} },
Resolve: func(*config, *Resolution) error { calls.resolve++; return nil },
Validate: func(*config, *Validation) { calls.validate++ },
},
}, testSecretBinding(), func(string) (string, bool) { calls.lookup++; return "", false })
if loaded != nil || err == nil {
t.Fatalf("mounted decode failure returned a usable result: %#v, %v", loaded, err)
}
if calls.defaults != 1 || calls.resolve != 0 || calls.lookup != 0 || calls.validate != 0 {
t.Fatalf("callbacks after mounted decode failure = %#v", calls)
}
if got := groupedErrorCode(t, err); got != CodeDecodeType {
t.Fatalf("mounted decode failure code = %s", got)
}
}
func indentDestinationBody(body string) string {
var result strings.Builder
for _, line := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
result.WriteString(" ")
result.WriteString(line)
result.WriteByte('\n')
}
return result.String()
}

View File

@@ -0,0 +1,210 @@
package engine
import (
"bytes"
"testing"
)
type destinationLoadConfig struct {
Main map[string]string `yaml:"main"`
Defaults map[string]string `yaml:"defaults"`
Collections map[string]map[string]string `yaml:"collections"`
Value string `yaml:"value"`
}
type destinationItemConfig struct {
Catalog struct {
Entries []map[string]string `yaml:"entries"`
} `yaml:"catalog"`
}
func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered[destinationItemConfig](
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
testSecretBinding(),
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
placementMode: placementModeSequenceItem,
}}}}},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0] == nil || len(result.Config.Catalog.Entries[0]) != 0 {
t.Fatalf("empty mapping item = %#v", result.Config.Catalog.Entries)
}
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "0")); ok {
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
}
sources := result.Sources()
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved {
t.Fatalf("source placement evidence = %#v", sources)
}
if got := sources[0].Destination.String(); got != "/catalog/entries" {
t.Fatalf("source destination = %q", got)
}
absent, err := LoadLayered[destinationItemConfig](
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
testSecretBinding(),
)
if err != nil {
t.Fatalf("NoSource item load: %v", err)
}
if got := len(testLoaded(absent).Config.Catalog.Entries); got != 0 {
t.Fatalf("absent item created %d entries", got)
}
}
func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
mode placementMode
path Path
}{
{name: "root item", mode: placementModeSequenceItem},
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
reader := bytes.NewBufferString("value: unused\n")
_, err := LoadLayered[destinationItemConfig](
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode},
testSecretBinding(),
)
if err == nil || reader.Len() == 0 {
t.Fatalf("preflight err %v, unread bytes %d", err, reader.Len())
}
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
t.Fatalf("preflight error = %#v", err)
}
})
}
}
func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
t.Parallel()
loaded, err := Load[destinationItemConfig](
Mapping("ordinary", map[string]any{}), testSecretBinding(),
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
)
if err != nil {
t.Fatalf("Load: %v", err)
}
result := testLoaded(loaded)
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0] == nil {
t.Fatalf("ordinary item placement = %#v", result.Config.Catalog.Entries)
}
}
func TestLayeredLoadPlacesNonImportMainAndUnderlayInputs(t *testing.T) {
t.Parallel()
destination := Keys("main")
main := Input{Source: Bytes("main", []byte("value: primary\n")), Destination: destination}
underlay := Input{Source: Bytes("defaults", []byte("value: fallback\n")), Destination: Keys("defaults")}
loaded, err := LoadLayered(main, testSecretBinding(), LayeredOptions[destinationLoadConfig]{
Underlays: []Underlay{{Inputs: []Input{underlay}}},
})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if result.Config.Main["value"] != "primary" || result.Config.Defaults["value"] != "fallback" {
t.Fatalf("mounted configuration = %#v", result.Config)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/main" || sources[1].Destination.String() != "/defaults" {
t.Fatalf("source destinations = %#v", sources)
}
destination.segments[0].key = "caller mutation"
sources[0].Destination.segments[0].key = "record mutation"
if got := result.Sources()[0].Destination.String(); got != "/main" {
t.Fatalf("source destination was not independently owned: %q", got)
}
}
func TestLayeredLoadOmittedAndEmptyDestinationsKeepRootPlacement(t *testing.T) {
t.Parallel()
inputs := []Input{
{Source: Bytes("omitted", []byte("value: same\n"))},
{Source: Bytes("empty", []byte("value: same\n")), Destination: Keys()},
}
for _, input := range inputs {
loaded, err := LoadLayered(input, testSecretBinding(), LayeredOptions[destinationLoadConfig]{})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if result.Config.Value != "same" {
t.Fatalf("root value = %q", result.Config.Value)
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Destination.String() != "" {
t.Fatalf("root source destination = %#v", sources)
}
}
}
func TestLayeredLoadDistinguishesEmptyBodyFromNoSource(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered(
Input{Source: NoSource(), Destination: Keys("absent")},
testSecretBinding(),
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("empty", []byte("{}\n")), Destination: Keys("collections", "one"),
}}}}},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
info, ok := result.Metadata().Lookup(Keys("collections", "one"))
if !ok || !info.Explicit || info.Origin.Layer != LayerUnderlay {
t.Fatalf("empty mounted mapping = %#v, %v", info, ok)
}
if _, ok := result.Metadata().Lookup(Keys("absent")); ok {
t.Fatal("NoSource materialized its destination")
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Label != "empty" || sources[0].Destination.String() != "/collections/one" {
t.Fatalf("sources = %#v", sources)
}
}
func TestLayeredLoadPreflightsDestinationsBeforeReadingSources(t *testing.T) {
t.Parallel()
reader := bytes.NewBufferString("value: unused\n")
_, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: Reader("reader", reader), Destination: Keys("configkit"),
}}}}},
)
if err == nil {
t.Fatal("reserved destination was accepted")
}
if reader.Len() == 0 {
t.Fatal("source reader was consumed before destination preflight")
}
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) != 1 {
t.Fatalf("error = %#v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
t.Fatalf("destination diagnostic = %#v", diagnostic)
}
}

View File

@@ -0,0 +1,181 @@
package engine
import (
"errors"
"io/fs"
"path"
"reflect"
"testing"
)
func TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords(t *testing.T) {
t.Parallel()
directory := t.TempDir()
sharedPath := writeImportFile(t, directory, "shared.yml", "section:\n shared: lower\n branch:\n child: old\n kept: retained\n")
shared := File(sharedPath)
load := func() (*Loaded[map[string]any], error) {
return LoadLayered(
Input{Source: NoSource()}, testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{
{Inputs: []Input{
{Source: shared, Destination: Keys("catalog")},
{Source: Bytes("peer", []byte("section:\n peer: present\n")), Destination: Keys("catalog")},
}},
{Inputs: []Input{{Source: shared, Destination: Keys("backup")}}},
{Inputs: []Input{{Source: Bytes("replacement", []byte("section:\n branch: replaced\n")), Destination: Keys("catalog")}}},
}},
)
}
loaded, err := load()
if err != nil {
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
}
result := testLoaded(loaded)
metadata := result.Metadata()
container, ok := metadata.Lookup(Keys("catalog"))
if !ok || !container.Explicit || len(container.Origin.Locations) < 3 {
t.Fatalf("mounted container origin = %#v, %v", container, ok)
}
containerSources := make(map[string]bool)
for _, location := range container.Origin.Locations {
containerSources[location.SourceID] = true
if location.Line < 1 {
t.Fatalf("introduced container lacks its source-root fallback: %#v", location)
}
}
for _, sourceID := range []string{"source-1", "source-2", "source-4"} {
if !containerSources[sourceID] {
t.Fatalf("container contributors = %#v, missing %s", container.Origin.Locations, sourceID)
}
}
sharedField, ok := metadata.Lookup(Keys("catalog", "section", "shared"))
if !ok || len(sharedField.Origin.Locations) != 1 || sharedField.Origin.Locations[0].SourceID != "source-1" || sharedField.Origin.Locations[0].Line != 2 {
t.Fatalf("authored leaf origin = %#v, %v", sharedField, ok)
}
peerField, ok := metadata.Lookup(Keys("catalog", "section", "peer"))
if !ok || len(peerField.Origin.Locations) != 1 || peerField.Origin.Locations[0].SourceID != "source-2" {
t.Fatalf("peer leaf origin = %#v, %v", peerField, ok)
}
if _, ok := metadata.Lookup(Keys("catalog", "section", "branch", "child")); ok {
t.Fatal("replaced descendant remains effective")
}
removed := metadata.History(Keys("catalog", "section", "branch", "child"))
if len(removed) < 2 || removed[0].Origin.Locations[0].SourceID != "source-1" {
t.Fatalf("removed descendant history = %#v", removed)
}
backup, ok := metadata.Lookup(Keys("backup", "section", "shared"))
if !ok || len(backup.Origin.Locations) != 1 || backup.Origin.Locations[0].SourceID != "source-3" {
t.Fatalf("second-tier leaf origin = %#v, %v", backup, ok)
}
sources := result.Sources()
if len(sources) != 4 || sources[0].Path != sources[2].Path || sources[0].Destination.String() != "/catalog" || sources[2].Destination.String() != "/backup" {
t.Fatalf("same source in separate tiers = %#v", sources)
}
if sources[0].Layer != LayerUnderlay || sources[0].UnderlayIndex != 1 || sources[2].Layer != LayerUnderlay || sources[2].UnderlayIndex != 2 {
t.Fatalf("tier source records = %#v", sources)
}
other, err := load()
if err != nil {
t.Fatalf("independent load: %v", err)
}
differences, err := Diff(loaded.Inspection, other.Inspection)
if err != nil || len(differences) != 0 {
t.Fatalf("equivalent inspection diff = %#v, %v", differences, err)
}
sources[0].Destination.segments[0].key = "mutated"
sharedField.Origin.Locations[0].SourceID = "mutated"
removed[0].Path.segments[0].key = "mutated"
if got := result.Sources()[0].Destination.String(); got != "/catalog" {
t.Fatalf("source record copy changed after inspection: %q", got)
}
fresh, ok := result.Metadata().Lookup(Keys("catalog", "section", "shared"))
if !ok || fresh.Origin.Locations[0].SourceID != "source-1" {
t.Fatalf("effective origin changed after mutating returned records: %#v, %v", fresh, ok)
}
if got := result.Metadata().History(Keys("catalog", "section", "branch", "child")); len(got) < 2 || got[0].Path.String() != "/catalog/section/branch/child" {
t.Fatalf("historical path changed after mutating returned records: %#v", got)
}
if got := other.Inspection.sources[0].Destination.String(); got != "/catalog" {
t.Fatalf("independent load destination changed: %q", got)
}
}
func TestLayeredImportedInheritedValuesRetainResourceContexts(t *testing.T) {
t.Parallel()
type entry struct {
Extends string `yaml:"extends"`
Resource string `yaml:"resource"`
Count int `yaml:"count"`
}
type config struct {
Catalog struct {
Entries map[string]entry `yaml:"entries"`
} `yaml:"catalog"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "config/main.yml", "child:\n extends: parent\n")
inputPath := writeImportFile(t, directory, "shared/input.yml", "configkit: {imports: [{path: parent.yml}]}\n")
writeImportFile(t, directory, "shared/parent.yml", "parent:\n resource: icon.txt\n count: 1\n")
writeImportFile(t, directory, "shared/icon.txt", "parent-resource")
var resolutionContext ResourceContext
loaded, err := LoadLayered(
Input{Source: File(mainPath), Destination: Keys("catalog", "entries")},
testSecretBinding(),
LayeredOptions[config]{
Underlays: []Underlay{{Inputs: []Input{{
Source: File(inputPath), Imports: true, Destination: Keys("catalog", "entries"),
}}}},
Definition: Definition[config]{Resolve: func(configuration *config, resolution *Resolution) error {
info, ok := resolution.InputInfo(Keys("catalog", "entries", "child", "resource"))
if !ok || len(info.Origin.Locations) != 1 || len(info.Origin.Inheritance) != 1 {
return errors.New("inherited resource lacks its source origin")
}
var found bool
resolutionContext, found = resolution.state.input.sourceContext(info.Origin.Locations[0].SourceID)
if !found || resolutionContext.BaseDir != "." || resolutionContext.FS == nil {
return errors.New("resolver could not find the declaring resource context")
}
data, err := fs.ReadFile(resolutionContext.FS, path.Join(resolutionContext.BaseDir, configuration.Catalog.Entries["child"].Resource))
if err != nil || string(data) != "parent-resource" {
return errors.New("resolver could not read the inherited relative resource")
}
return nil
}},
},
)
if err != nil {
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
}
result := testLoaded(loaded)
child := result.Config.Catalog.Entries["child"]
if child.Resource != "icon.txt" || child.Count != 1 || child.Extends != "" {
t.Fatalf("inherited child = %#v", child)
}
field, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", "resource"))
if !ok || len(field.Origin.Locations) != 1 || len(field.Origin.Inheritance) != 1 {
t.Fatalf("inherited resource origin = %#v, %v", field, ok)
}
context, ok := result.Inspection.SourceContext(field.Origin.Locations[0].SourceID)
if !ok || context.BaseDir != "." || context.FS == nil || resolutionContext.BaseDir != context.BaseDir {
t.Fatalf("completed resource contexts = %#v, %#v, %v", resolutionContext, context, ok)
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child.Resource))
if err != nil || string(data) != "parent-resource" {
t.Fatalf("completed source-relative resource = %q, %v", data, err)
}
if !reflect.DeepEqual(result.Config.Catalog.Entries["parent"], entry{Resource: "icon.txt", Count: 1}) {
t.Fatalf("surviving lower-tier parent = %#v", result.Config.Catalog.Entries["parent"])
}
for _, source := range result.Sources() {
if source.Layer == LayerUnderlay && source.Destination.String() != "/catalog/entries" {
t.Errorf("underlay destination = %#v", source)
}
}
}

View File

@@ -0,0 +1,287 @@
package engine
import (
"errors"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
t.Parallel()
parsed, err := ParsePath("/a~1b/0/~0")
if err != nil {
t.Fatal(err)
}
normalized, err := normalizeDestination(parsed)
if err != nil {
t.Fatal(err)
}
if got, want := normalized.String(), "/a~1b/0/~0"; got != want {
t.Fatalf("normalized path = %q, want %q", got, want)
}
for index, want := range []string{"a/b", "0", "~"} {
segment := normalized.segments[index]
if segment.kind != pathSegmentKey || segment.key != want {
t.Fatalf("segment %d = %#v, want key %q", index, segment, want)
}
}
original := Keys("owned", "path")
owned, err := normalizeDestination(original)
if err != nil {
t.Fatal(err)
}
owned.segments[0].key = "changed"
if got := original.String(); got != "/owned/path" {
t.Fatalf("normalizing shared path storage mutated its input: %q", got)
}
}
func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
t.Parallel()
body := &document.Node{
Kind: document.Mapping,
Mapping: []document.Entry{{
Key: "name", Value: &document.Node{Kind: document.String, Text: "entry"},
}},
}
destination := Keys("catalog", "entry")
path, zero, err := placeDocument(body, destination, body.Location, 5, 32)
if err != nil {
t.Fatal(err)
}
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
if err != nil {
t.Fatal(err)
}
if path.String() != explicitPath.String() || !reflect.DeepEqual(zero, explicit) {
t.Fatalf("zero placement (%q, %#v) differs from mapping mode (%q, %#v)", path.String(), zero, explicitPath.String(), explicit)
}
}
func TestAppendDestinationCopiesLiteralSegments(t *testing.T) {
t.Parallel()
parent := Keys("collection", "entry")
relative := Keys("a/b", "0", "~")
combined := appendDestination(parent, relative)
if got, want := combined.String(), "/collection/entry/a~1b/0/~0"; got != want {
t.Fatalf("combined destination = %q, want %q", got, want)
}
if len(combined.segments) != 5 {
t.Fatalf("combined segments = %#v", combined.segments)
}
for index, segment := range combined.segments {
if segment.kind != pathSegmentKey {
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
}
}
combined.segments[0].key = "changed parent"
combined.segments[2].key = "changed relative"
if got := parent.String(); got != "/collection/entry" {
t.Fatalf("combined path shares parent segment storage: %q", got)
}
if got := relative.String(); got != "/a~1b/0/~0" {
t.Fatalf("combined path shares relative segment storage: %q", got)
}
}
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
t.Parallel()
for name, path := range map[string]Path{
"explicit index": Keys("items").Index(0),
"negative index": Keys("items").Index(-1),
"invalid path": Path{invalid: true},
"configkit": Keys("configkit"),
"extends": Keys("extends"),
"nested configkit": Keys("outer", "configkit"),
"nested extends": Keys("outer", "extends"),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := normalizeDestination(path); !errors.Is(err, errInvalidDestination) {
t.Fatalf("normalizeDestination(%q) error = %v, want invalid destination", path.String(), err)
}
})
}
for _, key := range []string{"myextends", "configkit2", "", ".", "..", "../x", "a.b", "a/b", "~"} {
t.Run("ordinary key "+key, func(t *testing.T) {
t.Parallel()
if _, err := normalizeDestination(Keys(key)); err != nil {
t.Fatalf("normalizeDestination(%q): %v", key, err)
}
})
}
}
func TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates(t *testing.T) {
t.Parallel()
rootLocation := document.Location{SourceID: "source-a", Line: 1, Column: 1}
body := &document.Node{
Kind: document.Mapping,
Location: document.Location{SourceID: "source-a", Line: 4, Column: 1},
}
path, placed, err := placeDocument(body, Keys("a", "b"), rootLocation, 2, 5)
if err != nil {
t.Fatal(err)
}
if got, want := path.String(), "/a/b"; got != want {
t.Fatalf("normalized destination = %q, want %q", got, want)
}
if placed == body || len(placed.Mapping) != 1 || placed.Mapping[0].Key != "a" {
t.Fatalf("outer destination mapping was not created: %#v", placed)
}
inner := placed.Mapping[0].Value
if inner.Kind != document.Mapping || len(inner.Mapping) != 1 || inner.Mapping[0].Key != "b" {
t.Fatalf("inner destination mapping was not created: %#v", inner)
}
if inner.Mapping[0].Value != body || len(body.Mapping) != 0 {
t.Fatal("placement mutated or copied the authored body")
}
if placed.Location != rootLocation || inner.Location != rootLocation || placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
t.Fatal("introduced structure did not use the source-root coordinate fallback")
}
if body.Location != (document.Location{SourceID: "source-a", Line: 4, Column: 1}) {
t.Fatalf("authored body location changed: %#v", body.Location)
}
if got, want := countInputNodes(placed), countInputNodes(body)+4; got != want {
t.Fatalf("placed node count = %d, want body count plus four synthetic nodes (%d)", got, want)
}
}
func TestPlaceDocumentPreservesAuthoredEntryCoordinates(t *testing.T) {
t.Parallel()
rootLocation := document.Location{SourceID: "source-b", Line: 1, Column: 1}
bodyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 1}
keyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 3}
valueLocation := document.Location{SourceID: "source-b", Line: 4, Column: 5}
value := &document.Node{Kind: document.String, Location: valueLocation, Text: "value"}
body := &document.Node{
Kind: document.Mapping,
Location: bodyLocation,
Mapping: []document.Entry{{
Key: "leaf",
KeyLocation: keyLocation,
ValueLocation: valueLocation,
Value: value,
}},
}
_, placed, err := placeDocument(body, Keys("mounted"), rootLocation, 2, 5)
if err != nil {
t.Fatal(err)
}
if placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
t.Fatal("introduced destination entry did not use the source-root fallback")
}
if placed.Mapping[0].Value != body || body.Location != bodyLocation {
t.Fatal("placement changed the authored body node or location")
}
entry := body.Mapping[0]
if entry.KeyLocation != keyLocation || entry.ValueLocation != valueLocation || entry.Value != value || value.Location != valueLocation {
t.Fatal("placement changed authored entry or value coordinates")
}
}
func TestPlaceDocumentRootAndBounds(t *testing.T) {
t.Parallel()
body := &document.Node{
Kind: document.Mapping,
Mapping: []document.Entry{{
Key: "leaf",
Value: &document.Node{Kind: document.String, Text: "value"},
}},
}
path, placed, err := placeDocument(body, Path{}, body.Location, 1, 3)
if err != nil {
t.Fatal(err)
}
if path.String() != "" || placed != body {
t.Fatal("root placement must preserve the existing body")
}
destination := Keys("a", "b")
if !destinationWithinLimits(body, destination, 3, 7) {
t.Fatal("exact depth and node limits should admit the placed document")
}
if destinationWithinLimits(body, destination, 2, 7) {
t.Fatal("placement exceeding the depth limit was accepted")
}
if destinationWithinLimits(body, destination, 3, 6) {
t.Fatal("placement exceeding the node limit was accepted")
}
_, placed, err = placeDocument(body, destination, body.Location, 3, 6)
if !errors.Is(err, errDestinationLimit) || placed != nil {
t.Fatalf("bounded placement returned node %p and error %v", placed, err)
}
_, absent, err := placeDocument(nil, destination, document.Location{}, 0, 0)
if err != nil || absent != nil {
t.Fatalf("absent body should not consume placement limits: node %p, error %v", absent, err)
}
}
func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
t.Parallel()
root := document.Location{SourceID: "item", Line: 1, Column: 1}
body := &document.Node{Kind: document.Mapping, Location: root}
destination := Keys("catalog", "entries")
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6)
if err != nil {
t.Fatal(err)
}
if placedPath.String() != "/catalog/entries" || placed.Kind != document.Mapping {
t.Fatalf("placed root = %q %#v", placedPath.String(), placed)
}
container, ok := documentAtPath(placed, destination)
if !ok || container.Kind != document.Sequence || len(container.Sequence) != 1 || container.Sequence[0] != body {
t.Fatalf("item destination = %#v, present %v", container, ok)
}
if got, want := countInputNodes(placed), countInputNodes(body)+destinationWrapperNodeCount(destination)+1; got != want {
t.Fatalf("item node count = %d, want %d", got, want)
}
if container.Location != root || container.Sequence[0].Location != root {
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
}
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
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) {
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) {
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
}
for _, test := range []struct {
name string
depth int
nodes int
wantAccept bool
}{
{name: "exact allowance", depth: 3, nodes: 6, wantAccept: true},
{name: "one too shallow", depth: 2, nodes: 6},
{name: "one node short", depth: 3, nodes: 5},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes)
if test.wantAccept && err != nil {
t.Fatalf("exact bounds rejected: %v", err)
}
if !test.wantAccept && !errors.Is(err, errDestinationLimit) {
t.Fatalf("over-bound placement error = %v", err)
}
})
}
}

View File

@@ -0,0 +1,347 @@
package engine
import (
"path/filepath"
"reflect"
"testing"
"testing/fstest"
)
func TestLayeredDestinationStrictCompositionBoundaries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputs []Input
wantPath []string
want any
alsoPath []string
alsoWant any
}{
{
name: "disjoint peers at one destination",
inputs: []Input{
{Source: Bytes("left", []byte("left: one\n")), Destination: Keys("application")},
{Source: Bytes("right", []byte("right: two\n")), Destination: Keys("application")},
},
wantPath: []string{"application", "right"},
want: "two",
alsoPath: []string{"application", "left"},
alsoWant: "one",
},
{
name: "different destinations",
inputs: []Input{
{Source: Bytes("left", []byte("value: shared\n")), Destination: Keys("left")},
{Source: Bytes("right", []byte("value: shared\n")), Destination: Keys("right")},
},
wantPath: []string{"right", "value"},
want: "shared",
},
{
name: "ancestor and descendant destinations",
inputs: []Input{
{Source: Bytes("parent", []byte("branch: {base: one}\n")), Destination: Keys("application")},
{Source: Bytes("child", []byte("leaf: two\n")), Destination: Keys("application", "branch")},
},
wantPath: []string{"application", "branch", "leaf"},
want: "two",
alsoPath: []string{"application", "branch", "base"},
alsoWant: "one",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
Underlays: []Underlay{{Inputs: test.inputs}},
})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if got := layeredMapValue(t, result.Config, test.wantPath...); got != test.want {
t.Fatalf("value at %v = %#v, want %#v", test.wantPath, got, test.want)
}
if len(test.alsoPath) != 0 {
if got := layeredMapValue(t, result.Config, test.alsoPath...); got != test.alsoWant {
t.Fatalf("value at %v = %#v, want %#v", test.alsoPath, got, test.alsoWant)
}
}
})
}
for _, test := range []struct {
name string
left string
right string
}{
{name: "equal values conflict", left: "value: same\n", right: "value: same\n"},
{name: "unequal values conflict", left: "value: left\n", right: "value: right\n"},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
inputs := []Input{
{Source: Bytes("left", []byte(test.left)), Destination: Keys("application")},
{Source: Bytes("right", []byte(test.right)), Destination: Keys("application")},
}
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
Underlays: []Underlay{{Inputs: inputs}},
})
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
t.Fatalf("strict composition result = %#v, %v", loaded, err)
}
})
}
t.Run("strict conflict is not hidden by an upper override", func(t *testing.T) {
inputs := []Input{
{Source: Bytes("left", []byte("value: left\n")), Destination: Keys("application")},
{Source: Bytes("right", []byte("value: right\n")), Destination: Keys("application")},
}
loaded, err := LoadLayered(
Input{Source: Bytes("main", []byte("value: upper\n")), Destination: Keys("application")},
testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: inputs}}},
)
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
t.Fatalf("strict composition result = %#v, %v", loaded, err)
}
})
}
func TestLayeredDestinationMatchesExplicitlyWrappedInput(t *testing.T) {
t.Parallel()
load := func(input Input) map[string]any {
t.Helper()
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
Underlays: []Underlay{{Inputs: []Input{input}}},
})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
return testLoaded(loaded).Config
}
placed := load(Input{Source: Bytes("placed", []byte("field: value\n")), Destination: Keys("application")})
authored := load(Input{Source: Bytes("authored", []byte("application: {field: value}\n"))})
if !reflect.DeepEqual(placed, authored) {
t.Fatalf("placed configuration %#v differs from explicitly wrapped %#v", placed, authored)
}
}
func TestEmptyMountedMappingRetainsLowerChildren(t *testing.T) {
t.Parallel()
destination := Keys("collections", "weather")
loaded, err := LoadLayered(
Input{Source: Bytes("empty-main", []byte("{}\n")), Destination: destination},
testSecretBinding(),
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("lower", []byte("prompt: {title: retained}\n")), Destination: destination,
}}}}},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
if got := layeredMapValue(t, testLoaded(loaded).Config, "collections", "weather", "prompt", "title"); got != "retained" {
t.Fatalf("lower child beneath empty mounted mapping = %#v, want retained", got)
}
}
func TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("bundle", fstest.MapFS{
"overlays/test.yml": &fstest.MapFile{Data: []byte("settings:\n metadata:\n version: overlay\n")},
})
loaded, err := LoadLayered(
Input{Source: Bytes("main", []byte("mapping: {shared: main}\nsequence: [9]\ncleared: null\nenabled: false\nmetadata: {version: main}\n")), Destination: Keys("settings")},
testSecretBinding(),
LayeredOptions[map[string]any]{
Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("underlay", []byte("mapping: {retained: lower, shared: lower}\nsequence: [1, 2]\ncleared: {old: true}\nenabled: true\ncount: 7\nmetadata: {version: lower, label: retained}\n")),
Destination: Keys("settings"),
}}}},
Overlay: "test",
OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
Overrides: []Override{{Path: Keys("settings", "count"), Value: 0, Flag: "--count"}},
},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
settings := layeredMap(t, result.Config, "settings")
mapping := layeredMap(t, settings, "mapping")
if mapping["retained"] != "lower" || mapping["shared"] != "main" {
t.Fatalf("recursive mapping overlay = %#v", mapping)
}
sequence, ok := settings["sequence"].([]any)
if !ok || len(sequence) != 1 || sequence[0] != int64(9) {
t.Fatalf("sequence overlay = %#v", settings["sequence"])
}
if value, present := settings["cleared"]; !present || value != nil {
t.Fatalf("explicit null = %#v, present %v", value, present)
}
if value, ok := settings["enabled"].(bool); !ok || value {
t.Fatalf("explicit false = %#v", settings["enabled"])
}
if value, ok := settings["count"].(int64); !ok || value != 0 {
t.Fatalf("explicit zero override = %#v", settings["count"])
}
metadata := layeredMap(t, settings, "metadata")
if metadata["version"] != "overlay" || metadata["label"] != "retained" {
t.Fatalf("version-only overlay = %#v", metadata)
}
if _, present := settings["settings"]; present {
t.Fatalf("upper tiers were incorrectly nested under the main destination: %#v", result.Config)
}
}
func TestLayeredDestinationChecksVersionOnceOnComposedMain(t *testing.T) {
t.Parallel()
type appConfig struct {
Version int8 `yaml:"version"`
Count int `yaml:"count"`
Main string `yaml:"main"`
Imported string `yaml:"imported"`
Lower string `yaml:"lower"`
}
type config struct {
App appConfig `yaml:"app"`
}
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\nversion: 6\ncount: 4\nmain: main\n")},
"fragment.yml": &fstest.MapFile{Data: []byte("imported: included\n")},
})
checks := 0
loaded, err := LoadLayered(
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("app")},
testSecretBinding(),
LayeredOptions[config]{
Definition: Definition[config]{Version: &VersionRule{Path: Keys("app", "version"), Check: func(version Version, _ *Validation) error {
checks++
if !version.Present || version.Text != "6" {
t.Errorf("nested main version = %#v", version)
}
return nil
}}},
Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("lower", []byte("lower: underlay\n")), Destination: Keys("app"),
}}}},
},
)
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if checks != 1 {
t.Fatalf("version hook calls = %d, want 1", checks)
}
if result.Config.App.Version != 6 || result.Config.App.Count != 4 || result.Config.App.Main != "main" || result.Config.App.Imported != "included" || result.Config.App.Lower != "underlay" {
t.Fatalf("nested app = %#v", result.Config.App)
}
}
func TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths(t *testing.T) {
t.Parallel()
rule := &VersionRule{Path: Keys("app", "schema", "version"), Check: func(Version, *Validation) error { return nil }}
for _, test := range []struct {
name string
body string
wantCode Code
wantValue any
}{
{name: "underlay direct claim", body: "schema: {version: v2}\n", wantCode: CodeUnderlayVersion},
{name: "underlay ancestor claim", body: "schema: null\n", wantCode: CodeUnderlayVersion},
{name: "underlay mapping ancestor without claim", body: "schema: {name: ordinary}\n", wantValue: "ordinary"},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
loaded, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: rule},
Underlays: []Underlay{{Inputs: []Input{{
Source: Bytes("underlay", []byte(test.body)), Destination: Keys("app"),
}}}},
},
)
if test.wantCode != "" {
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
t.Fatalf("version claim result = %#v, %v", loaded, err)
}
return
}
if err != nil {
t.Fatalf("nonclaiming mapping ancestor was rejected: %v", err)
}
result := testLoaded(loaded)
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != test.wantValue {
t.Fatalf("nested ordinary value = %#v", got)
}
})
}
for _, test := range []struct {
name string
body string
wantCode Code
}{
{name: "imported direct claim", body: "schema: {version: v2}\n", wantCode: CodeImportVersion},
{name: "imported ancestor claim", body: "schema: null\n", wantCode: CodeImportVersion},
{name: "imported mapping ancestor without claim", body: "schema: {name: ordinary}\n"},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
directory := t.TempDir()
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
writeImportFile(t, directory, "fragment.yml", test.body)
loaded, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
LayeredOptions[map[string]any]{
Definition: Definition[map[string]any]{Version: rule},
Underlays: []Underlay{{Inputs: []Input{{
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("app"),
}}}},
},
)
if test.wantCode != "" {
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
t.Fatalf("version claim result = %#v, %v", loaded, err)
}
return
}
if err != nil {
t.Fatalf("nonclaiming imported mapping ancestor was rejected: %v", err)
}
result := testLoaded(loaded)
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != "ordinary" {
t.Fatalf("nested ordinary value = %#v", got)
}
})
}
}
func layeredMap(t *testing.T, value map[string]any, key string) map[string]any {
t.Helper()
result, ok := value[key].(map[string]any)
if !ok {
t.Fatalf("%q = %#v, want mapping", key, value[key])
}
return result
}
func layeredMapValue(t *testing.T, value map[string]any, keys ...string) any {
t.Helper()
for _, key := range keys[:len(keys)-1] {
value = layeredMap(t, value, key)
}
return value[keys[len(keys)-1]]
}

View File

@@ -63,8 +63,9 @@ func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
}
type diagnosticAccumulator struct {
diagnostics []Diagnostic
sources []SourceInfo
diagnostics []Diagnostic
sources []SourceInfo
versionState *sourceVersionState
}
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
@@ -74,9 +75,13 @@ func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
}
func (a *diagnosticAccumulator) loadError() *LoadError {
sources := a.sources
if a.versionState != nil {
sources = sourceInfosWithVersionEvidence(sources, a.versionState.evidence, nil, true)
}
return &LoadError{
diagnostics: canonicalDiagnostics(a.diagnostics),
sources: cloneSourceInfos(a.sources),
sources: cloneSourceInfos(sources),
}
}

98
internal/engine/diff.go Normal file
View File

@@ -0,0 +1,98 @@
package engine
import (
"bytes"
"errors"
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable")
var errDiffSnapshotUnavailable = errors.New("configkit: diff snapshot unavailable")
// Difference describes one canonical changed region with safe snapshots.
type Difference struct {
Path Path
Left ValueSnapshot
Right ValueSnapshot
HiddenChanged bool
}
type diffRegion struct {
path Path
left *document.Node
right *document.Node
}
func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
if left == nil || left.final == nil || right == nil || right.final == nil {
return nil, errDiffInspectionUnavailable
}
deltas := compareConfigurationValues(left.final, right.final)
regions := make([]diffRegion, 0, len(deltas))
for _, delta := range deltas {
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.immutableLookup)
if !leftPresent {
leftNode = nil
}
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup)
if !rightPresent {
rightNode = nil
}
regions = append(regions, diffRegion{path: delta.path, left: leftNode, right: rightNode})
}
return regions, nil
}
// Diff compares the final frozen values retained by two successful inspections.
// Values are serialized only after applying the combined sensitivity of both loads.
func Diff(left, right *Inspection) ([]Difference, error) {
regions, err := prepareDiffRegions(left, right)
if err != nil {
return nil, err
}
sensitivity := newDiffSensitivity(left.secrets, right.secrets)
differences := make([]Difference, 0, len(regions))
for _, region := range regions {
paired, supported := preparePairedDiffSnapshot(region.left, region.right, region.path, sensitivity)
if !supported {
return nil, errDiffSnapshotUnavailable
}
leftSnapshot, err := serializeDiffSnapshot(paired.left, paired.leftRedacted)
if err != nil {
return nil, errDiffSnapshotUnavailable
}
rightSnapshot, err := serializeDiffSnapshot(paired.right, paired.rightRedacted)
if err != nil {
return nil, errDiffSnapshotUnavailable
}
differences = append(differences, Difference{
Path: region.path,
Left: leftSnapshot,
Right: rightSnapshot,
HiddenChanged: paired.hiddenChanged,
})
}
return differences, nil
}
func serializeDiffSnapshot(node *document.Node, redacted bool) (ValueSnapshot, error) {
if node == nil {
return ValueSnapshot{}, nil
}
var output bytes.Buffer
if err := writeDiffDocument(&output, node); err != nil {
return ValueSnapshot{}, errDiffSnapshotUnavailable
}
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}, nil
}
func writeDiffDocument(output io.Writer, node *document.Node) error {
if err := yamladapter.Write(output, node); err != nil {
return errDiffSnapshotUnavailable
}
return nil
}

View File

@@ -0,0 +1,130 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestPreparePairedDiffSnapshotReportsWhollyMaskedChanges(t *testing.T) {
left := &document.Node{Kind: document.String, Text: "private-before"}
right := &document.Node{Kind: document.String, Text: "private-after"}
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("credential"), newDiffSensitivity(nil, map[string]bool{"/credential": true}),
)
if !supported || !paired.hiddenChanged || !paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("masked change = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, paired.left, "[REDACTED]")
assertDiffStringNode(t, paired.right, "[REDACTED]")
}
func TestPreparePairedDiffSnapshotDetectsRawChangesBehindIdenticalMarkers(t *testing.T) {
left := parseCompositionDocument(t, "left", "token: old-canary\n")
right := parseCompositionDocument(t, "right", "token: new-canary\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.hiddenChanged || paired.left.Text != paired.right.Text {
t.Fatalf("identically rendered masked values lost raw change: %#v, supported=%v", paired, supported)
}
}
func TestPreparePairedDiffSnapshotCombinesPublicAndHiddenChanges(t *testing.T) {
left := parseCompositionDocument(t, "left", "public: before\ntoken: old\n")
right := parseCompositionDocument(t, "right", "public: after\ntoken: new\n")
paired, supported := preparePairedDiffSnapshot(
left, right, Path{}, newDiffSensitivity(nil, map[string]bool{"/token": true}),
)
if !supported || !paired.hiddenChanged {
t.Fatalf("mixed public and hidden change = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before")
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after")
assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]")
}
func TestPreparePairedDiffSnapshotDoesNotFlagUnchangedMaskedValues(t *testing.T) {
left := parseCompositionDocument(t, "left", "public: before\ntoken: unchanged\n")
right := parseCompositionDocument(t, "right", "public: after\ntoken: unchanged\n")
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
if err != nil || len(regions) != 1 || regions[0].path.String() != "/public" {
t.Fatalf("changed regions = %#v, err = %v; want only /public", regions, err)
}
paired, supported := preparePairedDiffSnapshot(
left, right, Path{}, newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("unchanged masked values affected hidden-change state: %#v, supported=%v", paired, supported)
}
leftSequence := parseCompositionDocument(t, "left-sequence", "items: [unchanged, before]\n")
rightSequence := parseCompositionDocument(t, "right-sequence", "items: [unchanged, after]\n")
sequence, supported := preparePairedDiffSnapshot(
mappingValue(t, leftSequence, "items"), mappingValue(t, rightSequence, "items"), Keys("items"),
newDiffSensitivity(nil, map[string]bool{"/items/0": true}),
)
if !supported || !sequence.leftRedacted || !sequence.rightRedacted || sequence.hiddenChanged {
t.Fatalf("unchanged masked sequence member affected hidden-change state: %#v, supported=%v", sequence, supported)
}
}
func TestPreparePairedDiffSnapshotReportsMaskedAdditionAndRemoval(t *testing.T) {
value := &document.Node{Kind: document.String, Text: "private"}
tests := []struct {
name string
left, right *document.Node
leftHidden bool
rightHidden bool
}{
{name: "addition", right: value, rightHidden: true},
{name: "removal", left: value, leftHidden: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paired, supported := preparePairedDiffSnapshot(
test.left, test.right, Keys("token"), newDiffSensitivity(nil, map[string]bool{"/token": true}),
)
if !supported || !paired.hiddenChanged || paired.leftRedacted != test.leftHidden || paired.rightRedacted != test.rightHidden {
t.Fatalf("masked presence change = %#v, supported=%v", paired, supported)
}
if test.left == nil && paired.left != nil || test.right == nil && paired.right != nil {
t.Fatalf("absence became a value: %#v", paired)
}
})
}
}
func TestPreparePairedDiffSnapshotReportsHiddenSequenceLengthChange(t *testing.T) {
left := parseCompositionDocument(t, "left", "items: [public]\n")
right := parseCompositionDocument(t, "right", "items: [public, private]\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"),
newDiffSensitivity(nil, map[string]bool{"/items/1": true}),
)
if !supported || !paired.hiddenChanged || paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("masked sequence length change = %#v, supported=%v", paired, supported)
}
if len(paired.left.Sequence) != 1 || len(paired.right.Sequence) != 2 {
t.Fatalf("safe sequence lengths = %d, %d; want 1, 2", len(paired.left.Sequence), len(paired.right.Sequence))
}
assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]")
}
func TestPreparePairedDiffSnapshotClassificationAloneDoesNotCreateHiddenChange(t *testing.T) {
left := parseCompositionDocument(t, "left", "token: same\n")
right := parseCompositionDocument(t, "right", "token: same\n")
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
if err != nil || len(regions) != 0 {
t.Fatalf("classification-independent changed regions = %#v, err = %v", regions, err)
}
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("classification alone reported hidden change: %#v, supported=%v", paired, supported)
}
}

View File

@@ -0,0 +1,282 @@
package engine
import (
"bytes"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type diffIntegrationConfig struct {
Defaulted string `yaml:"defaulted"`
Dynamic map[string]any `yaml:"dynamic"`
Credential Secret `yaml:"credential" configkit:"secret"`
Derived string `yaml:"derived"`
}
type diffOptionalSecretConfig struct {
Optional *Secret `yaml:"optional" configkit:"secret"`
}
func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
var resolveCalls int
options := Options[diffIntegrationConfig]{Definition: Definition[diffIntegrationConfig]{
Defaults: func() diffIntegrationConfig {
return diffIntegrationConfig{Defaulted: "from-default", Dynamic: map[string]any{"defaulted": "retained"}}
},
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
resolveCalls++
configuration.Derived = configuration.Dynamic["visible"].(string)
resolution.Record(Keys("derived"), ChangeNormalization, "derive from effective values", Keys("credential"))
return nil
},
}}
input := func(value string) Source {
return Bytes("effective.yml", []byte("dynamic: {visible: "+value+"}\ncredential: DIFF_ENGINE_SECRET\n"))
}
t.Setenv("DIFF_ENGINE_SECRET", "credential-left-canary")
left, err := testLoad(input("before"), options)
if err != nil {
t.Fatal(err)
}
t.Setenv("DIFF_ENGINE_SECRET", "credential-right-canary")
right, err := testLoad(input("after"), options)
if err != nil {
t.Fatal(err)
}
if left.Config.Defaulted != "from-default" || right.Config.Defaulted != "from-default" {
t.Fatalf("defaulted values = %q, %q", left.Config.Defaulted, right.Config.Defaulted)
}
if !left.Inspection.secrets["/credential"] || !left.Inspection.secrets["/derived"] ||
!right.Inspection.secrets["/credential"] || !right.Inspection.secrets["/derived"] {
t.Fatalf("typed or dependency-derived sensitivity was not retained: left=%#v right=%#v", left.Inspection.secrets, right.Inspection.secrets)
}
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("derived"), left.Inspection.immutableLookup); !present || node.Text != "before" {
t.Fatalf("left frozen derived value = %#v, present=%v", node, present)
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil {
t.Fatal(err)
}
if len(differences) != 2 || differences[0].Path.String() != "/derived" || differences[1].Path.String() != "/dynamic/visible" {
t.Fatalf("differences = %#v", differences)
}
if !differences[0].Left.Redacted || !differences[0].Right.Redacted || !differences[0].HiddenChanged ||
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("dependency-derived difference = %#v", differences[0])
}
if differences[1].Left.Redacted || differences[1].Right.Redacted || differences[1].HiddenChanged ||
differences[1].Left.YAML != "before\n" || differences[1].Right.YAML != "after\n" {
t.Fatalf("public dynamic difference = %#v", differences[1])
}
for _, value := range differences {
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-") {
t.Fatal("diff snapshot exposed a resolved credential")
}
}
var effective bytes.Buffer
if err := left.Inspection.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "credential-left-canary") {
t.Fatal("standalone effective rendering exposed a credential after diff")
}
explanation, ok := left.Inspection.Explain(Keys("derived"))
if !ok || !explanation.Effective.Redacted || explanation.Effective.YAML != "'[REDACTED]'\n" {
t.Fatalf("explanation changed after diff: %#v, ok=%v", explanation, ok)
}
if _, ok := left.Inspection.Metadata().Lookup(Keys("dynamic", "visible")); !ok {
t.Fatal("metadata lookup failed after diff")
}
if resolveCalls != 2 {
t.Fatalf("resolution callbacks ran during diff: %d calls", resolveCalls)
}
// Returned records can be modified without affecting a later comparison.
differences[0].Path.segments[0].key = "changed-by-caller"
differences[0].Left.YAML = "changed-by-caller"
t.Setenv("DIFF_ENGINE_SECRET", "credential-after-load-canary")
repeated, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(repeated) != 2 || repeated[0].Path.String() != "/derived" || repeated[0].Left.YAML != "'[REDACTED]'\n" {
t.Fatalf("repeated diff = %#v, err = %v", repeated, err)
}
if resolveCalls != 2 {
t.Fatalf("repeated diff reran resolution: %d calls", resolveCalls)
}
independent, err := testLoad(input("before"), options)
if err != nil {
t.Fatal(err)
}
if resolveCalls != 3 {
t.Fatalf("independent load did not retain normal resolution behavior: %d calls", resolveCalls)
}
if _, err := Diff(left.Inspection, independent.Inspection); err != nil {
t.Fatalf("independent load comparison = %v", err)
}
}
func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
loadReference := func(name string) *testResult[diffOptionalSecretConfig] {
t.Helper()
result, err := testLoad[diffOptionalSecretConfig](Bytes("secret-reference.yml", []byte("optional: "+name+"\n")))
if err != nil {
t.Fatal(err)
}
return result
}
t.Setenv("DIFF_ROTATING_SECRET", "first-credential-canary")
left := loadReference("DIFF_ROTATING_SECRET")
t.Setenv("DIFF_ROTATING_SECRET", "second-credential-canary")
right := loadReference("DIFF_ROTATING_SECRET")
t.Setenv("DIFF_ROTATING_SECRET", "changed-after-load-canary")
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 0 {
t.Fatalf("credential rotation behind one reference = %#v, err = %v", differences, err)
}
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("optional"), left.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
t.Fatalf("frozen reference = %#v, present=%v", node, present)
}
if node, present := documentAtPathWithLookup(right.Inspection.final, Keys("optional"), right.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
t.Fatalf("right frozen reference = %#v, present=%v", node, present)
}
t.Setenv("DIFF_OTHER_SECRET", "other-credential-canary")
other := loadReference("DIFF_OTHER_SECRET")
differences, err = Diff(left.Inspection, other.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
t.Fatalf("reference-name change = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Redacted || !differences[0].HiddenChanged ||
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("classified reference-name diff = %#v", differences[0])
}
absent, err := testLoad[diffOptionalSecretConfig](Bytes("absent-secret.yml", []byte("{}\n")))
if err != nil {
t.Fatal(err)
}
if absent.Config.Optional != nil {
t.Fatalf("missing optional reference decoded as %#v", absent.Config.Optional)
}
if node, present := documentAtPathWithLookup(absent.Inspection.final, Keys("optional"), absent.Inspection.immutableLookup); !present || node.Kind != document.Null {
t.Fatalf("optional nil projection = %#v, present=%v", node, present)
}
differences, err = Diff(absent.Inspection, left.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
t.Fatalf("optional absence diff = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("optional reference change snapshots = %#v", differences[0])
}
for _, value := range differences {
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-canary") {
t.Fatal("reference diff exposed a resolved credential")
}
}
}
func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
type configuration struct {
Dynamic map[string]any `yaml:"dynamic"`
}
secretPath := Keys("dynamic", "token")
secretSelector := func(path Path) bool { return path.String() == secretPath.String() }
load := func(input string, resolve func(*configuration, *Resolution) error) *testResult[configuration] {
t.Helper()
result, err := testLoad(Bytes("historical-secret.yml", []byte(input)), Options[configuration]{Definition: Definition[configuration]{
Secret: secretSelector,
Resolve: resolve,
}})
if err != nil {
t.Fatal(err)
}
return result
}
t.Run("removed historical value", func(t *testing.T) {
left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
delete(configuration.Dynamic, "token")
resolution.Record(secretPath, ChangeNormalization, "remove token")
return nil
})
right := load("dynamic: {token: current-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("removed historical selector was not retained")
}
if _, present := documentAtPathWithLookup(left.Inspection.final, secretPath, left.Inspection.immutableLookup); present {
t.Fatal("removed token remains in the effective left tree")
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
t.Fatalf("historical removal diff = %#v, err = %v", differences, err)
}
if differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("historical removal snapshots = %#v", differences[0])
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
t.Fatal("historical removal diff exposed a sensitive value")
}
})
t.Run("replaced historical value", func(t *testing.T) {
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
configuration.Dynamic["token"] = "replacement-canary"
resolution.Record(secretPath, ChangeNormalization, "replace token")
return nil
})
right := load("dynamic: {token: other-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("replaced historical selector was not retained")
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
t.Fatalf("historical replacement diff = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("historical replacement snapshots = %#v", differences[0])
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
t.Fatal("historical replacement diff exposed a sensitive value")
}
})
}
func TestDiffReturnsSafeErrorsWithoutRecords(t *testing.T) {
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
for _, test := range []struct {
name string
left, right *Inspection
}{
{name: "nil left", right: valid},
{name: "nil right", left: valid},
{name: "missing final tree", left: &Inspection{}, right: valid},
} {
t.Run(test.name, func(t *testing.T) {
differences, err := Diff(test.left, test.right)
if differences != nil || err == nil || err != errDiffInspectionUnavailable || strings.Contains(err.Error(), "safe") {
t.Fatalf("differences, err = %#v, %v", differences, err)
}
})
}
}
func TestDiffSerializationErrorDoesNotExposeWriterDetails(t *testing.T) {
err := writeDiffDocument(diffErrorWriter{}, &document.Node{Kind: document.String, Text: "safe"})
if err != errDiffSnapshotUnavailable || strings.Contains(err.Error(), "CANARY") {
t.Fatalf("serialization error = %v", err)
}
}
type diffErrorWriter struct{}
func (diffErrorWriter) Write([]byte) (int, error) {
return 0, errors.New("CANARY_WRITER_ERROR")
}

View File

@@ -0,0 +1,48 @@
package engine
type diffSensitivity struct {
prefixes pathPrefixIndex
pointers []string
}
func newDiffSensitivity(left, right map[string]bool) *diffSensitivity {
sensitivity := &diffSensitivity{}
seen := make(map[string]struct{}, len(left)+len(right))
for _, classifications := range []map[string]bool{left, right} {
for pointer, classified := range classifications {
if !classified {
continue
}
if _, found := seen[pointer]; found {
continue
}
seen[pointer] = struct{}{}
id := len(sensitivity.pointers)
sensitivity.pointers = append(sensitivity.pointers, pointer)
sensitivity.prefixes.insert(pointer, id)
}
}
return sensitivity
}
func (sensitivity *diffSensitivity) isSensitiveAtOrAbove(pointer string) bool {
if sensitivity == nil {
return false
}
found := false
sensitivity.prefixes.visitAncestors(pointer, func(int) { found = true })
return found
}
func (sensitivity *diffSensitivity) hasStrictDescendant(pointer string) bool {
if sensitivity == nil {
return false
}
found := false
sensitivity.prefixes.visitDescendants(pointer, func(id int) {
if sensitivity.pointers[id] != pointer {
found = true
}
})
return found
}

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