93 Commits

Author SHA1 Message Date
137769f3f1 Document the ConfigKit v0.8.0 release 2026-10-17 04:58:01 +00:00
ef28eabaf8 Retire completed sequence composition plans 2026-10-17 04:56:51 +00:00
008d2ba9c8 Clarify when sequence identities are validated 2026-10-17 04:55:32 +00:00
8e3a0892d4 Project item conflict diagnostics through enclosing scopes 2026-10-17 04:54:01 +00:00
023e428b9c Index sequence assembly and history lookups 2026-10-17 04:52:20 +00:00
527a38daf1 Preserve defaults-only input absence 2026-10-17 04:49:33 +00:00
30bcd8eeb6 Protect the main version inside item scopes 2026-10-17 04:48:25 +00:00
884215c403 Honor parsed sequence indexes in version protections 2026-10-17 04:46:53 +00:00
8a89806e44 Explain descendants removed by sequence replacement 2026-10-17 04:45:49 +00:00
26720b98f0 Preserve item helper history in source order 2026-10-17 04:44:29 +00:00
4633a1b8a2 Project consumed version evidence through sequence appends 2026-10-17 04:42:23 +00:00
87986a12d2 Record sequence composition review findings 2026-10-17 01:50:02 +00:00
0858af1029 Report malformed sequence items at assigned paths 2026-10-16 19:41:48 +00:00
acddce8e2f Document sequence composition for consumers 2026-10-16 19:37:59 +00:00
77c5d9482a Close cross-cutting sequence regression gaps 2026-10-16 19:32:31 +00:00
adef16ccdd Expose sequence placement controls in the public API 2026-10-16 19:23:43 +00:00
c19825620c Project version protections through item placement 2026-10-16 19:11:08 +00:00
190431740a Apply sequence policy across loading tiers 2026-10-16 18:59:52 +00:00
25cf6d32b6 Assemble imported sequence-item scopes 2026-10-16 16:20:14 +00:00
1a04bb3835 Add private sequence-item placement 2026-10-16 15:55:29 +00:00
1cf3b0d78f Retain ordered acquisition scopes 2026-10-16 15:48:36 +00:00
66dced0823 Project history and sensitivity paths 2026-10-16 15:44:44 +00:00
b1b5163cc3 Preserve append history across tiers 2026-10-16 15:36:46 +00:00
bb9bde799a Project sequence origins onto appended indexes 2026-10-16 15:29:27 +00:00
dc7a70811a Compose registered sequences by identity 2026-10-16 15:25:06 +00:00
ff212885c4 Prepare sequence registration preflight 2026-10-16 15:19:32 +00:00
90c2d7e409 Clarify unresolved source destination evidence in implementation plan 2026-10-16 14:49:23 +00:00
60e6e2697f Plan sequence placement and composition 2026-10-16 14:36:46 +00:00
20511773fc Settle sequence placement and composition roadmap decisions 2026-10-16 14:22:59 +00:00
31fb6a9bd1 Extend sequence roadmap with item placement and open decisions 2026-10-16 14:18:36 +00:00
103e10639a Plan identity-based sequence composition 2026-10-16 14:05:43 +00:00
97c5a0390a Retire deferred scope and preserve future performance experiments 2026-10-16 13:53:34 +00:00
8b547765f0 Record selective source version requirements as future work 2026-10-16 13:42:37 +00:00
5215cf5c4f Catalog identified record sequences as future work 2026-10-16 12:07:49 +00:00
e66c37161c Retire completed roadmaps before the next release 2026-10-16 11:36:21 +00:00
e4270c9b14 Allow absent version headers below structural controls 2026-10-15 22:22:56 +00:00
e69ad0f32d Keep complete paths in missing version evidence 2026-10-15 22:20:20 +00:00
0a2246bdbe Check import opt-in before processing source headers 2026-10-15 22:18:57 +00:00
e5a6df7af7 Preserve header sensitivity across colliding path shapes 2026-10-15 22:17:49 +00:00
25cb3c47e7 Record source version review findings 2026-10-15 22:11:44 +00:00
020d72084d Document shared source version checks 2026-10-15 16:23:39 +00:00
358b28c421 Add source version adoption example 2026-10-15 16:19:24 +00:00
b0550af6a3 Audit source version lifecycle agreement 2026-10-15 16:17:21 +00:00
43c994a32a Publish detached sanitized source evidence 2026-10-15 16:12:21 +00:00
1e47ee21cd Propagate source evidence sensitivity through history 2026-10-15 16:00:01 +00:00
fed591183b Classify source version evidence at original paths 2026-10-15 15:55:16 +00:00
1f95cf905a Expose source version modes in load options 2026-10-15 15:48:59 +00:00
15f5b591b9 Check source versions in selected overlays 2026-10-15 15:44:04 +00:00
184f29c7af Check source versions in direct underlays 2026-10-15 15:40:11 +00:00
813d6a38c9 Validate source versions in recursive imports 2026-10-15 15:33:40 +00:00
23ae700194 Integrate source version checks for main inputs 2026-10-15 15:24:53 +00:00
49218a836b Add source-local version extraction 2026-10-15 15:15:05 +00:00
2fcf892324 Separate version interpretation from reporting 2026-10-15 15:04:57 +00:00
8645cab79a Record shared source version policy 2026-10-15 15:02:50 +00:00
70c95bf660 Plan shared source version checks 2026-10-15 14:24:51 +00:00
d9f08ff719 Settle source version edge-case decisions 2026-10-15 14:05:25 +00:00
2c91e41d10 Record source version decisions and remaining edge cases 2026-10-14 20:30:58 +00:00
e3860036f8 Plan shared source version checks 2026-10-14 19:03:25 +00:00
b3fc6a5950 Restore the original input destination decision 2026-10-14 18:05:48 +00:00
a858a066e4 Record relative import destination review findings 2026-10-14 17:52:14 +00:00
10edd95d4d Complete relative import destination documentation audit 2026-10-14 17:45:31 +00:00
08e03f6a38 Demonstrate entry-declared import destinations 2026-10-14 17:41:35 +00:00
a20ba0af27 Verify relative imports through the application lifecycle 2026-10-14 17:39:28 +00:00
6458dbd3aa Preserve import provenance and resource contexts 2026-10-14 17:34:52 +00:00
43e55dfa85 Verify accumulated import resource accounting 2026-10-14 17:29:54 +00:00
430985c629 Verify relative import composition and version ownership 2026-10-14 17:27:49 +00:00
4635ef08d5 Add relative destinations to import declarations 2026-10-14 17:24:57 +00:00
49c0c6d908 Pass effective destinations through import traversal 2026-10-14 17:21:07 +00:00
a8beb3e620 Record relative import destination decision and baseline 2026-10-14 17:17:50 +00:00
934c1022e0 Record PromptKit feature request decisions 2026-10-14 17:09:23 +00:00
7248a8bd95 Plan relative import destination implementation 2026-10-14 16:53:50 +00:00
b2db3233b2 Settle relative import destination syntax 2026-10-14 16:44:11 +00:00
edb6d05ec1 Plan relative import destinations 2026-10-14 16:39:10 +00:00
315f464dfc Correct mounted mapping diagnostic paths 2026-10-14 15:49:51 +00:00
a029a2a754 Record input destination review findings 2026-10-14 14:12:18 +00:00
945e111c4e Complete input destination implementation audit 2026-10-14 14:06:38 +00:00
2d8df22dd1 Add mounted collection consumer examples 2026-10-14 13:59:37 +00:00
53601e68ed Expose input destinations through the public facade 2026-10-14 13:56:22 +00:00
751787ec24 Verify mounted inheritance and sensitivity integration 2026-10-14 13:50:08 +00:00
f50122d9f8 Verify mounted provenance and resource contexts 2026-10-14 13:38:56 +00:00
c71faf2065 Count destination wrappers in shared input limits 2026-10-14 13:31:32 +00:00
2f5d57ce1d Verify mounted diagnostic paths and locations 2026-10-14 13:25:55 +00:00
a1c557603f Verify mounted composition and version ownership 2026-10-14 13:24:06 +00:00
a2fab66aba Carry destinations through imported configuration 2026-10-14 13:19:31 +00:00
2f1f7f4b8a Integrate private destinations for layered inputs 2026-10-14 13:14:06 +00:00
667a536ae7 Add private input destination placement helpers 2026-10-14 13:09:06 +00:00
ea55ab43ce Record input destination decision and baseline 2026-10-14 13:05:09 +00:00
d3d468a8b4 Plan configuration input destination implementation 2026-10-14 12:48:08 +00:00
6353598bd6 Settle input destination roadmap decisions 2026-10-14 12:40:06 +00:00
3c2d7facb3 Plan configuration input destinations 2026-10-14 03:27:34 +00:00
6fffde4af8 Prepare ConfigKit v0.7.1 release 2026-10-11 01:14:23 +00:00
9e04a397c4 Upgrade ReleaseKit to v0.4.1 2026-10-11 01:10:26 +00:00
c54a9bf51e Upgrade ReleaseKit to v0.4.0 2026-10-10 23:51:44 +00:00
96 changed files with 12366 additions and 365 deletions

View File

@@ -26,6 +26,13 @@ the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe [consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows. its safe changed-region records and maintained workflows.
Sources can also be explicitly placed as sequence items, and definitions can
register selected sequences to append records with distinct string IDs during
strict composition. Higher-priority sequences still replace lower-priority
sequences as whole values. See the [sequence contract](docs/config.md#strict-document-composition),
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
```go ```go
type Config struct { type Config struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
@@ -62,18 +69,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 imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay and explicit CLI overrides sit and above typed defaults; a selected overlay and explicit CLI overrides sit
above it. The [configuration contract](docs/config.md#strict-document-composition) 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 The [architecture goals](docs/policy/architecture.md#success-criteria) explain
these priorities and how genuine new needs can justify extending the shared 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 The [configuration contract](docs/config.md) describes implemented behavior,
[consumer adoption guide](docs/consumers/README.md) provides maintained simple including [input destinations](docs/config.md#input-destinations). The
native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes. [consumer adoption guide](docs/consumers/README.md) provides maintained simple,
See the [development guide](docs/development.md) for repository orientation and the layered, mounted-collection, and identified-prompt examples alongside the
[release procedure](docs/release.md) for maintainer-only candidate and publication migration shapes. Input placement decisions are recorded in
steps. [ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
See the [development guide](docs/development.md) for repository orientation and
the [release procedure](docs/release.md) for maintainer-only candidate and
publication steps.
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT). ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

@@ -43,10 +43,31 @@ func toEngineLocations(values []Location) []engine.Location {
return result return result
} }
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), 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)}
} }
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), 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)}
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil {
return nil
}
return &SourceVersionCheck{
Path: fromEnginePath(value.Path),
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
}
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
if value == nil {
return nil
}
return &engine.SourceVersionCheck{
Path: toEnginePath(value.Path),
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
}
} }
func fromEngineOrigin(value engine.Origin) Origin { func fromEngineOrigin(value engine.Origin) Origin {
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input. missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver, `Options` accepts the application definition, ordinary-source `Destination` and
semantic validator, explicit path and glob imports, one selected overlay, and `PlacementMode`, explicit path and glob imports, one selected overlay, filesystem
filesystem context. It also accepts explicit CLI override records, static secret context, explicit CLI overrides, and shared source-version controls. The definition
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays, may register strict sequence composition paths through
and filesystem context as invalid combinations but accepts overrides. Input `SequenceCompositions`. Its zero-valued placement mode places the source mapping
containing a reserved `configkit` mapping key is rejected while imports are at the destination; `sequence-item` explicitly wraps the mapping as one item at a
disabled. non-root destination. Unknown modes, invalid destinations, and invalid or
conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. `NoSource` rejects
imports, active overlays, and filesystem context as invalid combinations but
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
while imports are disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
@@ -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 Byte, source, node, and glob-entry budgets are shared across the load, while
the existing per-source and import-depth bounds still apply. 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) 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, shows a main host file, two underlay tiers, embedded defaults, a mapping source,
and explicit resource access without a second configuration pass. 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 ### Sibling inheritance
@@ -269,6 +325,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 diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures. successful result. Callback panics remain programming failures.
#### Shared source-version checks
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
one `Definition.Version` policy applies across source documents. The zero value
`SourceVersionModeDefault` preserves the existing main-only check at its
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
checks each participating document that declares a version and permits absent
non-main declarations. `SourceVersionModeRequired` rejects a participating
document without a declaration. Enabled modes require a usable `VersionRule`
and checker; an unknown mode or unusable rule is an options error before source
access or callbacks.
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
document's own coordinates, before placement and composition. The main document,
direct underlay documents, imported documents (including transitive and glob
matches), and the selected overlay participate. `NoSource`, absent optional
imports, empty underlay groups, defaults, resolution output, inheritance, and
synthetic CLI overrides are not documents to check. An absent declaration in an
actual main document still reaches the checker in if-present mode; required mode
rejects it first. With `NoSource`, the existing single absent-main checker
behavior remains in both enabled modes. If-present omissions in non-main
documents skip the checker. Explicit null is present and reaches the checker;
unsupported selected values produce `version.type`. No equality with the main
declaration or non-null rule is imposed. Acceptance establishes only that the
declaration is accepted by the consumer's checker; it does not establish that
each fragment conforms to a complete application schema. Final assembled
decoding and application validation still run once.
A header that is absent below a structural control follows the same omission
policy. Actual header/control overlap is rejected, and ordinary structural
validation and main-version ownership protection continue independently.
Accepted non-main declarations are consumed from their owned payload before
composition; their authored ancestors remain, even if empty. The main
declaration remains in the application. Ordinary release metadata should use a
different path. Enabled main protection uses the main input's effective path:
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
the remaining payload against imports, underlays, inheritance, and CLI
overrides. Thus a mounted main looks up a local header while ownership checks
protect its destination-prefixed application path. Main checker diagnostics and
declaration locations use source-local coordinates; ordinary payload
diagnostics continue to use effective paths. Default-mode diagnostics are
unchanged.
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
occur. Otherwise it records a source-local `Path`, the observed `Version` state
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
available `Location`, and whether text was redacted. A non-nil record with
`Present == false` records observed absence; it does not say the checker ran or
accepted a declaration. Absent evidence still identifies the full requested
header path, while its location identifies the nearest available source node.
During callbacks and in failed-load source records,
text is conservatively withheld because final classification may not be
available. Successful final source records reflect completed sensitivity
classification at the declaration's mounted path and ancestors, including
historical classification. Redaction empties `Text` while retaining presence
and null state. Returned copies are detached; evidence does not reopen inputs.
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
decision and the [development guide](development.md#baseline-validation) for
the completed implementation and remediation records in Git history.
### Layered metadata ### Layered metadata
`Result.Metadata` describes every final canonical path. Queries retain explicit `Result.Metadata` describes every final canonical path. Queries retain explicit
@@ -299,6 +416,11 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived effective. Equal-valued input is still an explicit base definition, while a derived
value for absent input remains non-explicit. value for absent input remains non-explicit.
Appending registered records during strict composition assigns each contribution
its assembled sequence index in history. A later precedence tier that supplies a
sequence records replacement at the sequence path; history does not imply
identity-based merging across tiers.
A mapping assembled from the selected file and imports has one composite base origin A mapping assembled from the selected file and imports has one composite base origin
whose locations retain source traversal order. A mapping also visited by an overlay whose locations retain source traversal order. A mapping also visited by an overlay
has an overlay-layer composite origin with the lower contributing locations followed has an overlay-layer composite origin with the lower contributing locations followed
@@ -646,12 +768,47 @@ Application defaults do not participate in this operation.
| Mapping | Mapping | Recurse by mapping key | | Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` | | Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
Every non-mapping overlap is a conflict, even when values are equal. Nulls and Every non-mapping overlap is a conflict, even when values are equal, except for
sequences are present atomic values, so they also conflict when repeated; sequences sequence/sequence contributions at a registered sequence-composition path. Nulls
are never joined by index. Disjoint entries under shared mappings compose, making remain atomic conflicts. Unregistered sequences conflict when repeated and are
never joined by index. Disjoint entries under shared mappings compose, making
stable keyed mappings the natural representation for independently contributed stable keyed mappings the natural representation for independently contributed
entries. entries.
`Definition.SequenceCompositions` registers literal mapping paths and one literal
`IdentityField` for sequences that should compose independently authored records.
Registration paths follow input-destination key rules: parsed numeric tokens are
literal mapping keys, explicit index segments and reserved `configkit` or
mapping-valued `extends` segments are invalid, and the root registration is
harmless and unused. The identity field is one literal string key; neither the
field name nor identity strings are normalized.
At each registered path, every actual sequence contribution in a strict tier is
validated, including a singleton contribution: each record must be a mapping with
a present string identity field. Distinct exact identity strings append in source
and list order; duplicate identities conflict even if the records are otherwise
equal or disjoint. Empty identity strings are valid. Malformed records produce
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
sequence container and source locations identifying a duplicate without exposing
identity values. Missing paths and non-sequence values do not fail by themselves.
Identical registrations coalesce; conflicting registrations at one path and
invalid registration paths produce `options.invalid`. Unused and root registrations
are allowed and have no effect.
Registration changes strict composition only. Each underlay group and the main
tier has independent duplicate-identity state. Precedence assembly is unchanged:
a present higher-priority sequence replaces the lower sequence as one value,
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
and application resolution do not run the registered identity check. The accepted
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows item path and glob imports, helper-supplied identity, and main-tier
replacement of a lower-priority sequence.
When an enabled source-version mode is selected, declarations are checked in each
source's own coordinates before placement and composition. Accepted non-main
headers are consumed before strict composition and cannot supply a registered
record identity. Final typed decoding and application validation still run on the
completed configuration.
An empty mapping contributes no child definitions. Two empty mappings compose, and An empty mapping contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children. an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite Shared mapping containers retain all contributing value locations as their composite
@@ -751,9 +908,13 @@ zero.
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before `configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and string `path` or `glob`, an optional boolean `optional`, and an optional
fields are `import.invalid`. A `configkit` key below the root is invalid. With `destination` sequence of strings. Every destination string is one literal
imports disabled, any occurrence remains `import.disabled`. mapping key and follows the input-destination validation rules above; quote
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
and non-string members are `import.invalid`, as are all other unsupported shapes
and fields. A `configkit` key below the root is invalid. With imports disabled,
any occurrence remains `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0. The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to There is no legacy alias: `_configkit` is ordinary application data, subject to
@@ -782,6 +943,30 @@ walk failures, matched directories, invalid documents, escapes, and repeated tar
remain errors. Final candidates are bounded by the remaining source limit before remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated. their contents are accumulated.
Each path or glob 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 optional unmatched
path or glob contributes nothing.
For host files, the configuration root defaults to the selected file's canonical 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 directory. A host file may supply `FilesystemContext.Root` to widen that root but
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context

View File

@@ -17,8 +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 | | 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 | | 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 | | 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 | | 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 | | Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data, executable examples and ordinary tests. It contains no private application data,
@@ -52,6 +55,18 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts. execution profile concepts.
## Shared source-version checks
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption,
main-path protection, default behavior, and evidence disclosure. Accepted
declarations do not replace final assembled decoding or application validation.
## Effective configuration diffs ## Effective configuration diffs
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files [`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
@@ -108,6 +123,65 @@ and may fail independently of the completed configuration load. The
[configuration contract](../config.md#source-resource-contexts) owns exact [configuration contract](../config.md#source-resource-contexts) owns exact
lifetime and precedence rules. lifetime and precedence rules.
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
counterpart: its entry opts into imports through `Load`, imports metadata at the
root, and places a prompt beneath `prompts` using YAML. The executable example
and behavior test are in
[`layered_test.go`](../../examples/consumers/layered_test.go).
## Input destinations and mounted collections
Main and underlay inputs use the same destination field. An import without a
`destination` inherits its declaring document's effective destination; an
explicit sequence of key strings appends a relative suffix. Nested imports
append to their immediate parent's effective destination, while filesystem
paths remain relative to the declaring file. Destinations are application
mapping paths, independent of filesystem context. The configuration contract
owns the complete behavior and validation rules.
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
mounts one mapping source beneath literal collection/prompt keys and loads a
second input without a destination to show that root placement remains the
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
to interpret.
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
embedded collection manifest mounted beneath a literal collection path. The
entry's YAML places nested metadata imports at the collection root and prompt
and profile peers beneath their own keys. A higher-priority input changes only
one prompt's ordinary `version` field. The main document alone supplies the
application schema version. A prompt's local `extends` relationship resolves
after all tiers, so `custom` inherits the final `brief` content and fields while
retaining its own profile selection.
The resolver follows each effective `content_file` origin to its declaring
source context. This also works for `custom`'s inherited resource. The
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
demonstrates the same lookup after loading; ConfigKit does not open or interpret
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
checks source destinations and authored coordinates, presence, inherited values,
relative resources, and strict peer conflicts. The
[configuration contract](../config.md#input-destinations) owns shared placement,
import, source-location, and ownership behavior.
## Identified prompt sequences
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
definition registration for the literal `prompts` path and `id` field. Its main
file imports one prompt by path and others by glob, placing each source as an
item. A nested ordinary helper import supplies an item's ID. The main tier
strictly appends the distinct records in contribution order; its completed
sequence replaces the lower-priority default collection as one value.
The example's `validatePromptCatalog` remains application-owned. It checks that
prompt names and message lists satisfy this consumer's requirements after
ConfigKit has composed, projected, and decoded the configuration. It does not
add recursive record merging, identity-based overlay, or final uniqueness checks
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
[import syntax](../config.md#explicit-imports), and
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
boundaries.
Known module catalogs should use canonical object entries with a module identifier Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime- fields are known; validation selects which fields apply to each module. Runtime-
@@ -187,6 +261,7 @@ focused behavioral owner:
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | | `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests | | 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` | | Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` | | Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests | | Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |

View File

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

View File

@@ -8,8 +8,15 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners: Implemented component owners:
- `internal/engine` owns host and native-filesystem source acquisition, - `internal/engine` owns host and native-filesystem source acquisition,
grouped strict composition, recursive precedence assembly, and destination-local grouped strict composition, recursive precedence assembly, per-input mapping
sibling inheritance before typed decoding. It also owns schema 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 destination-local sibling
inheritance before typed decoding, plus shared source-version checking with
sanitized declaration evidence.
It retains each source's effective placement alongside physical source
identity and owns schema
inspection, lifecycle coordination, callback auditing, inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and provenance/history, passive source-context retention, secret resolution and
bootstrap, safe inspection rendering, and effective-value comparison over bootstrap, safe inspection rendering, and effective-value comparison over
@@ -41,12 +48,23 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types. internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics, including [effective configuration diffs](../config.md#effective-configuration-diffs). 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 [consumer adoption guide](../consumers/README.md) owns application guidance.
The [architecture policy](../policy/architecture.md) owns contributor The [architecture policy](../policy/architecture.md) owns contributor
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
decision. See the [development guide](../development.md) for validation commands decision. See the [development guide](../development.md) for validation commands
and [release procedure](../release.md) for source-candidate gates. [ADR and [release procedure](../release.md) for source-candidate gates. [ADR
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in 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.

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-09-29 **Last Updated**: 2026-10-16
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md) **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 ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. The [component overview](../internal/overview.md) and resolution across consuming applications. The [component overview](../internal/overview.md)
owns implementation inventory, the [configuration contract](../config.md) owns owns implementation inventory, the [configuration contract](../config.md) owns
implemented behavior, and the [deferred scope](../roadmap/deferred.md) preserves implemented behavior, and the [future feature list](../roadmap/future.md) preserves
deferred scope. candidates for future consideration.
## Adopted Constraints And Implemented Structure ## Adopted Constraints And Implemented Structure
@@ -99,6 +99,15 @@ types, field meanings, defaults, normalization, semantic validation, and schema
version policy. Applications also own main-file search conventions, CLI wiring, version policy. Applications also own main-file search conventions, CLI wiring,
remote acquisition, runtime initialization, and resource checks. remote acquisition, runtime initialization, and resource checks.
The engine applies each input's mapping destination within the existing source
assembly, applies immediate-parent-relative YAML destinations to imports, and
retains each effective placement with source identity and inspection records;
the root facade owns the public fields and conversions. The
[configuration contract](../config.md#input-destinations) defines the observable
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
the import extension.
Library code must not depend on consuming applications or initialize their Library code must not depend on consuming applications or initialize their
runtime resources. It must not perform implicit network access, discover runtime resources. It must not perform implicit network access, discover
application-specific paths, or change process-global state during ordinary `Load`. application-specific paths, or change process-global state during ordinary `Load`.
@@ -154,13 +163,23 @@ framework on this library.
Within a configuration tier, independent contributions use strict composition: Within a configuration tier, independent contributions use strict composition:
shared mappings combine recursively, while overlapping non-mapping definitions shared mappings combine recursively, while overlapping non-mapping definitions
are conflicts, including equal values. Between precedence tiers, intentional are conflicts, including equal values, except that sequences at explicitly
overlay combines mappings recursively, retains omitted lower-priority children, registered paths append records with distinct present string identities. Between
precedence tiers, intentional overlay combines mappings recursively, retains
omitted lower-priority children,
and replaces other present values according to the existing overlay rules. and replaces other present values according to the existing overlay rules.
Tier precedence is explicit; traversal order within a tier establishes stable Tier precedence is explicit; traversal order within a tier establishes stable
reporting, not permission to override. The distinction and its rationale are reporting, not permission to override. The distinction and its rationale are
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md). recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
Sequence-item placement and sequence registration are explicit source and
definition controls; registration does not change precedence replacement. The
[configuration contract](../config.md#strict-document-composition) defines the
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
shows the implemented subset. This feature does not authorize arbitrary
configurable merge strategies.
The main document and its imports form a strictly composed tier. Explicit The main document and its imports form a strictly composed tier. Explicit
underlay groups compose strictly within each group, then combine recursively underlay groups compose strictly within each group, then combine recursively
from lowest to highest beneath the main tier. Source acquisition kind does not from lowest to highest beneath the main tier. Source acquisition kind does not
@@ -234,7 +253,7 @@ for safe messages supplied by their hooks.
Do not introduce automatic directory discovery, network providers, arbitrary Do not introduce automatic directory discovery, network providers, arbitrary
merge strategies, additional configuration languages, or application runtime merge strategies, additional configuration languages, or application runtime
management without concrete consumer evidence and an explicit design decision. management without concrete consumer evidence and an explicit design decision.
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope) Record significant decisions to exclude capabilities, and their rationale, in
owns the deferred-feature inventory. Significant boundary ADRs. Significant boundary changes require an ADR and corresponding
changes require an ADR and corresponding policy/contract updates; implementation policy/contract updates; implementation convenience alone is not a reason to
convenience alone is not a reason to broaden the public API. broaden the public API.

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.11 **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) **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 implemented. Current architecture, user, operator, integration, and internal
documentation are updated when the behavior lands. documentation are updated when the behavior lands.
`docs/roadmap/deferred.md` owns deliberate exclusions; task-specific plans `docs/roadmap/future.md` owns potential enhancements and their open questions;
own execution order and progress. Completed plans retire during release task-specific plans own execution order and progress. Record significant
preparation. 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 ### 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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 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. changes, create a new ADR and update the earlier ADR's status to superseded.
Rejected architectural alternatives belong in the ADR; rejected product ideas Rejected architectural alternatives and significant architectural exclusions
belong in the roadmap. Routine choices need no ADR. 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 ## Maintenance

View File

@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version "$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. 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 `go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
configkit "gitea.maximumdirect.net/eric/ConfigKit" 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 var bundledFiles embed.FS
// BundledConfig is a small configuration loaded directly from an embedded file. // BundledConfig is a small configuration loaded directly from an embedded file.
@@ -18,6 +18,30 @@ type BundledConfig struct {
Enabled bool `yaml:"enabled"` 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. // LoadBundled demonstrates a native filesystem source without layered options.
func LoadBundled() (*configkit.Result[BundledConfig], error) { func LoadBundled() (*configkit.Result[BundledConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/native-source") files, err := fs.Sub(bundledFiles, "testdata/native-source")

View File

@@ -20,6 +20,34 @@ func Example_nativeFilesystem() {
// Output: embedded false // 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() { func Example_layeredCatalog() {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml")) result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil { if err != nil {
@@ -32,6 +60,58 @@ func Example_layeredCatalog() {
// Output: production false 0 bundle example Welcome from the bundle. <nil> // 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) { func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml")) result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil { if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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]]
}

View File

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

669
facade_sequence_test.go Normal file
View File

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

View File

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

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 go.yaml.in/yaml/v4 v4.0.0-rc.6
require ( 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 github.com/yuin/goldmark/v2 v2.1.6 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/mod v0.40.0 // indirect
) )

4
go.sum
View File

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

View File

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

View File

@@ -1,12 +1,60 @@
package engine package engine
import ( import (
"fmt"
"reflect" "reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
policy := sequencePolicy{identityFields: make(map[string]string)}
var inputs []*document.Node
for source := 0; source < 3; source++ {
var text strings.Builder
for collection := 0; collection < 128; collection++ {
key := fmt.Sprintf("records%d", collection)
policy.identityFields[Keys(key).String()] = "id"
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
}
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
}
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
working := &document.Node{Kind: document.Mapping}
for _, application := range composed.applications {
applyHistoryApplication(&working, application, new(mappingIndex))
}
if !configurationValuesEqual(working, composed.root) {
t.Fatal("wide registered collections replay differs from assembly")
}
for collection := 0; collection < 128; collection++ {
path := Keys(fmt.Sprintf("records%d", collection)).Index(2).Key("id")
origin := composed.origins[path.String()]
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
t.Fatalf("shifted origin at %s = %#v", path.String(), origin)
}
local, ok := historyApplicationLocalPath(composed.applications[2], path)
if !ok || local.String() != Keys(fmt.Sprintf("records%d", collection)).Index(0).Key("id").String() {
t.Fatal("wide local path projection failed")
}
}
clone := cloneComposedDocument(composed)
for _, original := range composed.applications[0].sequences {
pointer := original.path.String()
copy := clone.applications[0].sequencesByPath[pointer]
copy.path.segments[0].key = "changed"
if composed.applications[0].sequencesByPath[pointer].path.String() != pointer {
t.Fatal("history path index aliases cloned state")
}
break
}
}
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) { func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
base := parseCompositionDocument(t, "source-1", `services: base := parseCompositionDocument(t, "source-1", `services:
api: api:
@@ -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 { func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
t.Helper() t.Helper()
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits()) parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())

View File

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

View File

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

View File

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

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,574 @@
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
optional: true
`)
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

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

View File

@@ -12,6 +12,10 @@ const directiveKey = "configkit"
type importDeclaration struct { type importDeclaration struct {
path string path string
glob string glob string
destination Path
placement placementMode
placementPath Path
placementLocation *Location
optional bool optional bool
location Location location Location
} }
@@ -69,7 +73,7 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
if node.Kind != document.Mapping { if node.Kind != document.Mapping {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)} return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
} }
declaration := importDeclaration{location: publicLocation(node.Location)} declaration := importDeclaration{location: publicLocation(node.Location), placementPath: declarationPath.clone()}
seenTarget := false seenTarget := false
for _, entry := range node.Mapping { for _, entry := range node.Mapping {
fieldPath := declarationPath.Key(entry.Key) fieldPath := declarationPath.Key(entry.Key)
@@ -90,6 +94,42 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)} return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
} }
declaration.optional = entry.Value.Text == "true" declaration.optional = entry.Value.Text == "true"
case "destination":
if entry.Value.Kind != document.Sequence {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", entry.Value)}
}
relative := Path{segments: make([]pathSegment, 0, len(entry.Value.Sequence))}
for index, item := range entry.Value.Sequence {
memberPath := fieldPath.Index(index)
if item.Kind != document.String {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)}
}
member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}}
if _, err := normalizeDestination(member); err != nil {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)}
}
relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text})
}
normalized, err := normalizeDestination(relative)
if err != nil {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", entry.Value)}
}
declaration.destination = normalized
case "placement":
declaration.placementPath = fieldPath.clone()
placementLocation := publicLocation(entry.Value.Location)
declaration.placementLocation = &placementLocation
if entry.Value.Kind != document.String {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
}
switch entry.Value.Text {
case "mapping":
declaration.placement = placementModeMapping
case "sequence-item":
declaration.placement = placementModeSequenceItem
default:
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
}
default: default:
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)} return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
} }
@@ -123,26 +163,54 @@ func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
} }
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node { func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
return fragmentVersionClaimProjected(root, rule, -1, nil)
}
func fragmentVersionClaimProjected(root *document.Node, rule *VersionRule, sourceIndex int, ranges []sequenceRange) *document.Node {
return fragmentVersionClaimWithIndex(root, rule, sourceIndex, indexSequenceRanges(ranges))
}
func fragmentVersionClaimWithIndex(root *document.Node, rule *VersionRule, sourceIndex int, ranges sequenceRangeIndex) *document.Node {
if rule == nil { if rule == nil {
return nil return nil
} }
current := root current := root
currentPath := Path{}
if len(rule.Path.segments) == 0 { if len(rule.Path.segments) == 0 {
return current return current
} }
for _, segment := range rule.Path.segments { for _, segment := range rule.Path.segments {
if current.Kind != document.Mapping { switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return current return current
} }
if segment.kind == pathSegmentIndex { index := documentEntryIndex(current.Mapping, segment.key)
return nil
}
key := segment.key
index := documentEntryIndex(current.Mapping, key)
if index < 0 { if index < 0 {
return nil return nil
} }
current = current.Mapping[index].Value current = current.Mapping[index].Value
currentPath = currentPath.Key(segment.key)
case document.Sequence:
index, valid := schemaIndex(segment)
if !valid {
return current
}
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: currentPath.String()}]; found {
if index < assigned.start || index >= assigned.end {
return nil
}
index -= assigned.start
}
if index < 0 || index >= len(current.Sequence) {
return nil
}
current = current.Sequence[index]
currentPath = currentPath.Index(index)
default:
// A scalar or null at an ancestor replaces the protected subtree.
return current
}
} }
return current return current
} }

View File

@@ -49,6 +49,8 @@ type historyApplication struct {
path Path path Path
value *document.Node value *document.Node
origin Origin origin Origin
sequences []sequenceHistoryContribution
sequencesByPath map[string]sequenceHistoryContribution
} }
func cloneHistoryApplications(values []historyApplication) []historyApplication { func cloneHistoryApplications(values []historyApplication) []historyApplication {
@@ -59,33 +61,115 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
for index, value := range values { for index, value := range values {
cloned[index] = historyApplication{ cloned[index] = historyApplication{
layer: value.layer, path: value.path.clone(), value: value.value, layer: value.layer, path: value.path.clone(), value: value.value,
origin: cloneOrigin(value.origin), origin: cloneOrigin(value.origin), sequences: cloneSequenceHistoryContributions(value.sequences),
} }
cloned[index].sequencesByPath = indexSequenceHistoryContributions(cloned[index].sequences)
} }
return cloned return cloned
} }
// walkHistoryApplication visits a retained source tree with both its local
// semantic path and its effective projected path. Sequence fragments keep
// their original tree indexes, while the effective path uses the assigned
// range recorded by strict composition.
func walkHistoryApplication(application historyApplication, visit func(local, effective Path, node *document.Node)) {
if application.value == nil {
return
}
var walk func(*document.Node, Path, Path)
walk = func(node *document.Node, local, effective Path) {
if node == nil {
return
}
visit(local, appendDestination(application.path, effective), node)
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
walk(entry.Value, local.Key(entry.Key), effective.Key(entry.Key))
}
case document.Sequence:
contribution, projected := sequenceHistoryAt(application.sequencesByPath, local)
for index, child := range node.Sequence {
effectiveIndex := index
if projected {
effectiveIndex += contribution.offset
}
walk(child, local.Index(index), effective.Index(effectiveIndex))
}
}
}
walk(application.value, Path{}, Path{})
}
// historyApplicationLocalPath translates an effective projected path back to
// the retained source tree, including indexes shifted by enclosing appends.
func historyApplicationLocalPath(application historyApplication, effective Path) (Path, bool) {
relative, ok := relativeHistoryPath(application.path, effective)
if !ok {
return Path{}, false
}
local := Path{}
for _, segment := range relative.segments {
if contribution, found := sequenceHistoryAt(application.sequencesByPath, local); found {
index, valid := schemaIndex(segment)
if !valid || segment.kind == pathSegmentKey || index < contribution.offset || index >= contribution.offset+contribution.length {
return Path{}, false
}
local = local.Index(index - contribution.offset)
continue
}
local.segments = append(local.segments, segment)
}
return local, true
}
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) { func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
if !application.path.valid() || application.value == nil { if !application.path.valid() || application.value == nil {
return Path{}, false return Path{}, false
} }
if application.path.String() != "" { if !pathContains(application.path, query) {
if pathsIntersect(application.path, query) { if pathContains(query, application.path) {
return application.path.clone(), true return application.path.clone(), true
} }
return Path{}, false return Path{}, false
} }
if len(query.segments) == 0 { sequencePathMatched := false
return Path{}, true for _, contribution := range application.sequences {
container := appendDestination(application.path, contribution.path)
if !pathContains(container, query) && !pathContains(query, container) {
continue
} }
for length := 1; length <= len(query.segments); length++ { sequencePathMatched = true
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)} if pathContains(container, query) && len(query.segments) > len(container.segments) {
local, ok := historyApplicationLocalPath(application, query)
if contribution.mode == sequenceHistoryReplace {
if _, present := documentAtPathWithLookup(application.value, local, lookup); !ok || !present {
// A replacement also changes descendants missing from the
// incoming list. Attribute their removal to its container.
return container, true
}
}
if !ok {
continue
}
}
return query.clone(), true
}
if sequencePathMatched {
return Path{}, false
}
if len(query.segments) == len(application.path.segments) || application.value.Kind != document.Mapping {
return application.path.clone(), true
}
relative, _ := relativeHistoryPath(application.path, query)
for length := 1; length <= len(relative.segments); length++ {
prefix := Path{segments: clonePathSegments(relative.segments[:length], 0)}
node, present := documentAtPathWithLookup(application.value, prefix, lookup) node, present := documentAtPathWithLookup(application.value, prefix, lookup)
if !present { if !present {
return Path{}, false return Path{}, false
} }
if length == len(query.segments) || node.Kind != document.Mapping { if length == len(relative.segments) || node.Kind != document.Mapping {
return prefix, true return appendDestination(application.path, prefix), true
} }
} }
return Path{}, false return Path{}, false
@@ -96,7 +180,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
return cloneOrigin(application.origin) return cloneOrigin(application.origin)
} }
origin := Origin{Layer: application.layer} origin := Origin{Layer: application.layer}
relative, ok := relativeHistoryPath(application.path, operationPath) relative, ok := historyApplicationLocalPath(application, operationPath)
if !ok { if !ok {
return origin return origin
} }
@@ -119,13 +203,17 @@ func pathsIntersect(left, right Path) bool {
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) { func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
if application.path.String() == "" { if application.path.String() == "" {
mergeHistoryDocument(root, application.value, index) mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequencesByPath)
return return
} }
mergeHistoryAtPath(root, application.path, application.value, index) mergeHistoryAtPath(root, application.path, application.value, index)
} }
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) { func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
}
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContribution) {
if incoming == nil { if incoming == nil {
return return
} }
@@ -141,8 +229,54 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node, index
index.append(*target, cloned) index.append(*target, cloned)
continue continue
} }
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index) entryPath := path.Key(entry.Key)
if contribution, found := sequenceHistoryAt(sequences, entryPath); found {
applySequenceHistory(&(*target).Mapping[position].Value, entry.Value, contribution)
continue
} }
mergeHistoryDocumentAtPath(&(*target).Mapping[position].Value, entry.Value, entryPath, index, sequences)
}
}
func sequenceHistoryAt(contributions map[string]sequenceHistoryContribution, path Path) (sequenceHistoryContribution, bool) {
contribution, found := contributions[path.String()]
return contribution, found
}
func indexSequenceHistoryContributions(values []sequenceHistoryContribution) map[string]sequenceHistoryContribution {
if len(values) == 0 {
return nil
}
index := make(map[string]sequenceHistoryContribution, len(values))
for _, value := range values {
index[value.path.String()] = value
}
return index
}
func applySequenceHistory(target **document.Node, incoming *document.Node, contribution sequenceHistoryContribution) {
if incoming == nil || incoming.Kind != document.Sequence {
return
}
if contribution.mode == sequenceHistoryReplace || *target == nil || (*target).Kind != document.Sequence {
*target = document.Clone(incoming)
return
}
for _, item := range incoming.Sequence {
(*target).Sequence = append((*target).Sequence, document.Clone(item))
}
}
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
if values == nil {
return nil
}
cloned := make([]sequenceHistoryContribution, len(values))
for index, value := range values {
cloned[index] = value
cloned[index].path = value.path.clone()
}
return cloned
} }
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) { func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
@@ -152,7 +286,19 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
} }
current := *root current := *root
for segmentIndex, segment := range path.segments { for segmentIndex, segment := range path.segments {
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex { if segment.kind == pathSegmentIndex {
position, ok := schemaIndex(segment)
if current.Kind != document.Sequence || !ok || position >= len(current.Sequence) {
return
}
if segmentIndex == len(path.segments)-1 {
mergeHistoryDocument(&current.Sequence[position], incoming, index)
return
}
current = current.Sequence[position]
continue
}
if current.Kind != document.Mapping {
return return
} }
position := index.find(current, segment.key) position := index.find(current, segment.key)

View File

@@ -0,0 +1,240 @@
package engine
import (
"bytes"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/other": "id"}}
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
t.Fatalf("compose diagnostics = %#v", diagnostics)
}
for applicationIndex, application := range composed.applications {
for path, want := range map[string]int{"/records": 1, "/other": 1} {
sequence, present := documentAtPath(application.value, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want {
t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present)
}
}
}
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nother: [{id: default}]\n")
index := new(mappingIndex)
for _, application := range composed.applications {
applyHistoryApplication(&working, application, index)
}
for path, want := range map[string][]string{
"/records": {"a", "b"},
"/other": {"x", "y"},
} {
sequence, present := documentAtPath(working, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
t.Fatalf("replayed %s = %#v", path, sequence)
}
for itemIndex, id := range want {
field := sequence.Sequence[itemIndex].Mapping[0].Value.Text
if field != id {
t.Errorf("replayed %s[%d].id = %q, want %q", path, itemIndex, field, id)
}
}
}
application := composed.applications[1]
cloned := cloneHistoryApplications(composed.applications)
cloned[1].sequences[0].path.segments[0].key = "mutated"
if composed.applications[1].sequences[0].path.String() != "/records" {
t.Fatal("cloned sequence history path aliases its source")
}
addedPath := Keys("records").Index(1).Key("value")
if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() {
t.Fatalf("appended value operation = %s, %v", operation.String(), relevant)
}
if _, relevant := applicationOperationPath(application, Keys("records").Index(0).Key("value"), nil); relevant {
t.Fatal("later contribution was reported as changing an earlier record")
}
origin := historyApplicationOrigin(application, addedPath, nil)
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
t.Fatalf("appended value origin = %#v", origin)
}
overlay := historyApplication{
layer: LayerOverlay,
value: parseCompositionDocument(t, "overlay", "records: []\n"),
}
applyHistoryApplication(&working, overlay, index)
replaced, present := documentAtPath(working, Keys("records"))
if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 {
t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present)
}
}
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/independent": "id"}}
first, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"),
parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("first tier diagnostics = %#v", diagnostics)
}
for index := range first.applications {
first.applications[index].layer = LayerUnderlay
}
second, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "underlay-2", "records: [{id: shared}]\nindependent: []\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("second tier diagnostics = %#v", diagnostics)
}
main, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "main", "records: []\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("main diagnostics = %#v", diagnostics)
}
assembly := newAssemblyAccumulator(first)
if diagnostics := assembly.applyOverlay(second.root, second.origins, second.applications, LayerUnderlay); len(diagnostics) != 0 {
t.Fatalf("second tier handoff diagnostics = %#v", diagnostics)
}
if diagnostics := assembly.applyOverlay(main.root, main.origins, main.applications, LayerBase); len(diagnostics) != 0 {
t.Fatalf("main handoff diagnostics = %#v", diagnostics)
}
if len(assembly.current.applications) != 4 {
t.Fatalf("retained source applications = %d, want 4", len(assembly.current.applications))
}
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nindependent: [{id: default}]\n")
index := new(mappingIndex)
for _, application := range assembly.current.applications {
applyHistoryApplication(&working, application, index)
}
for path, want := range map[string][]string{
"/records": nil,
"/independent": nil,
} {
sequence, present := documentAtPath(working, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
t.Errorf("replayed replacement %s = %#v, want an explicit empty sequence", path, sequence)
}
}
// The same identity may appear in separate tiers, while each tier still
// retains its individual source applications for explanation replay.
if got := []Layer{assembly.current.applications[0].layer, assembly.current.applications[1].layer,
assembly.current.applications[2].layer, assembly.current.applications[3].layer}; !reflect.DeepEqual(got, []Layer{LayerUnderlay, LayerUnderlay, LayerUnderlay, LayerBase}) {
t.Fatalf("application layers = %#v", got)
}
}
func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) {
root := parseCompositionDocument(t, "base", "records: [{id: one, helper: {value: old}}]\n")
application := historyApplication{
path: Keys("records").Index(0).Key("helper"),
value: parseCompositionDocument(t, "helper", "value: new\n"),
}
applyHistoryApplication(&root, application, new(mappingIndex))
value, present := documentAtPath(root, Keys("records").Index(0).Key("helper").Key("value"))
if !present || value.Text != "new" {
t.Fatalf("indexed helper replay = %#v, %v", value, present)
}
}
func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
composed, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
parseCompositionDocument(t, "second", "records: [{id: second, token: private}]"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose diagnostics = %#v", diagnostics)
}
path := Keys("records").Index(1).Key("token")
var metadata Metadata
finalizeMetadataPaths(&metadata, composed.root, composed.applications)
if !metadata.hasHistoricalPath(path) {
t.Fatalf("projected metadata omitted shifted path %s: %#v", path, metadata.historicalPaths)
}
inspection := &Inspection{
metadata: metadata, baseline: &document.Node{Kind: document.Mapping},
input: composed.root, final: composed.root, applications: composed.applications,
}
inspection.secrets = classifySecretPaths(nil, func(candidate Path) bool {
return reflect.DeepEqual(candidate, path)
}, metadata, composed.root, composed.applications)
explanation, ok := inspection.Explain(path)
if !ok || !explanation.Effective.Redacted {
t.Fatalf("shifted explanation = %#v, %v", explanation, ok)
}
var foundSecondOrigin bool
for _, change := range explanation.History {
for _, location := range change.Origin.Locations {
foundSecondOrigin = foundSecondOrigin || location.SourceID == "second"
}
}
if !foundSecondOrigin {
t.Fatalf("shifted history lost second source attribution: %#v", explanation.History)
}
var rendered bytes.Buffer
if err := inspection.WriteExplanation(&rendered, path); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), `path: "/records/1/token"`) || strings.Contains(rendered.String(), "private") {
t.Fatalf("shifted explanation rendering is incomplete or unsafe: %s", rendered.String())
}
}
func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
composed, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
parseCompositionDocument(t, "second", "records: [{id: second, token: PRIVATE-RECORD-CANARY}]"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose diagnostics = %#v", diagnostics)
}
applications := append([]historyApplication(nil), composed.applications...)
applications = append(applications, historyApplication{
layer: LayerOverlay,
value: parseCompositionDocument(t, "replacement", "records: [{id: replacement, token: visible}]"),
})
final := applications[len(applications)-1].value
path := Keys("records").Index(1).Key("token")
var metadata Metadata
finalizeMetadataPaths(&metadata, final, applications)
inspection := &Inspection{
metadata: metadata, baseline: &document.Node{Kind: document.Mapping},
input: final, final: final, applications: applications,
}
inspection.secrets = classifySecretPaths(nil, func(candidate Path) bool {
return reflect.DeepEqual(candidate, path)
}, metadata, final, applications)
explanation, ok := inspection.Explain(path)
if !ok || explanation.Effective.Present {
t.Fatalf("replaced shifted record explanation = %#v, %v", explanation, ok)
}
var rendered bytes.Buffer
if err := inspection.WriteExplanation(&rendered, path); err != nil {
t.Fatal(err)
}
if strings.Contains(rendered.String(), "PRIVATE-RECORD-CANARY") || !strings.Contains(rendered.String(), "[REDACTED]") {
t.Fatalf("replaced sensitive history leaked or was not redacted: %s", rendered.String())
}
}
func mustParsePath(t *testing.T, value string) Path {
t.Helper()
path, err := ParsePath(value)
if err != nil {
t.Fatal(err)
}
return path
}

View File

@@ -49,8 +49,12 @@ type importTraversal struct {
accounting *importAccounting accounting *importAccounting
limits importLimits limits importLimits
rule *VersionRule rule *VersionRule
versionState *sourceVersionState
documents []*document.Node scope acquisitionScope
itemScope bool
itemPrefix Path
itemFallback Path
sources []SourceInfo sources []SourceInfo
active map[resourceIdentity]int active map[resourceIdentity]int
visited map[resourceIdentity]importRoute visited map[resourceIdentity]importRoute
@@ -58,16 +62,28 @@ type importTraversal struct {
} }
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedDocumentsTracked(source, context, main, rule, limits, &importAccounting{}, nil, nil) return loadImportedDocumentsTracked(source, context, main, Path{}, rule, limits, &importAccounting{}, nil, nil)
} }
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) { func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedDocumentsTrackedWithVersion(source, context, main, destination, rule, limits, accounting, visited, hostToken, nil)
}
func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) {
var processing *sourceVersionProcessingResult
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processing = versionState.mainProcessing
}
return loadImportedDocumentsTrackedWithProcessing(source, context, main, destination, rule, limits, accounting, visited, hostToken, versionState, processing, sequencePolicy{})
}
func loadImportedDocumentsTrackedWithProcessing(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
if nativeContext(source, context) { if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context) access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil { if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic} return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
} }
return loadImportedWithAccessTracked(access, basePath, mainPath, main, rule, limits, accounting, visited) return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
} }
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main) rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil { if diagnostic != nil {
@@ -82,14 +98,30 @@ func loadImportedDocumentsTracked(source Source, context *FilesystemContext, mai
if hostToken != nil { if hostToken != nil {
access.token = hostToken access.token = hostToken
} }
return loadImportedWithAccessTracked(access, basePath, mainPath, main, rule, limits, accounting, visited) return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
} }
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) { func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedWithAccessTracked(access, basePath, mainPath, main, rule, limits, accounting, nil) return loadImportedWithAccessTracked(access, basePath, mainPath, main, Path{}, rule, limits, accounting, nil)
} }
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) { func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedWithAccessTrackedWithVersion(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, nil)
}
func loadImportedWithAccessTrackedWithVersion(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) {
var processing *sourceVersionProcessingResult
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processing = versionState.mainProcessing
}
return loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
}
func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, sequencePolicy{})
}
func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) { if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
_ = access.close() _ = access.close()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)} return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
@@ -102,6 +134,8 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
} }
} }
mainInfo := cloneSourceInfo(*main.source) mainInfo := cloneSourceInfo(*main.source)
mainInfo.Destination = destination.clone()
mainIsItem := main.source.placementMode == placementModeSequenceItem
if mainPath != "" { if mainPath != "" {
mainInfo.Path = mainPath mainInfo.Path = mainPath
} }
@@ -117,11 +151,18 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
accounting.bytes += main.bytes accounting.bytes += main.bytes
accounting.nodes += countInputNodes(main.document) accounting.nodes += countInputNodes(main.document)
state := importTraversal{ state := importTraversal{
access: access, accounting: accounting, limits: limits, rule: rule, access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
documents: make([]*document.Node, 0, 1), scope: acquisitionScope{destination: destination.clone()},
sources: []SourceInfo{mainInfo}, sources: []SourceInfo{mainInfo},
active: make(map[resourceIdentity]int), visited: visited, active: make(map[resourceIdentity]int), visited: visited,
} }
if mainIsItem {
state.itemScope = true
state.itemPrefix = destination.clone()
state.itemFallback = destination.clone()
state.scope.destination = Path{}
state.sources[0] = cloneSourceInfo(mainInfo)
}
if state.visited == nil { if state.visited == nil {
state.visited = make(map[resourceIdentity]importRoute) state.visited = make(map[resourceIdentity]importRoute)
} }
@@ -138,21 +179,118 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
state.active[mainKey] = 0 state.active[mainKey] = 0
state.visited[mainKey] = mainRoute state.visited[mainKey] = mainRoute
state.stack = append(state.stack, mainRoute) state.stack = append(state.stack, mainRoute)
body, declarations, diagnostics := splitImportDirectives(main.document) var body *document.Node
var declarations []importDeclaration
var diagnostics []Diagnostic
if processing != nil {
body = processing.payload
declarations = processing.imports
} else {
body, declarations, diagnostics = splitImportDirectives(main.document)
}
if len(diagnostics) == 0 { if len(diagnostics) == 0 {
state.documents = append(state.documents, body) if mainIsItem {
diagnostics = state.walkDeclarations(basePath, declarations, 0) state.scope.contributions = append(state.scope.contributions, acquiredContribution{
source: cloneSourceInfo(mainInfo), payload: body, document: body, processing: processing,
})
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{})
} else {
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
placementNodes := destinationWrapperNodeCount(destination)
if err != nil {
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} else if accounting.nodes+placementNodes > limits.nodes {
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
} else {
accounting.nodes += placementNodes
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
destination: destination.clone(), processing: processing,
})
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
}
}
} }
closeErr := access.close() closeErr := access.close()
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
if mainIsItem {
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
}
return nil, cloneSourceInfos(state.sources), diagnostics return nil, cloneSourceInfos(state.sources), diagnostics
} }
if closeErr != nil { if closeErr != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)} return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
} }
composed, diagnostics := composeDocuments(state.documents...) if mainIsItem {
localScope := state.scope
documents := make([]*document.Node, 0, len(localScope.contributions))
for _, contribution := range localScope.contributions {
documents = append(documents, contribution.document)
}
completed, failures := composeDocuments(documents...)
if len(failures) != 0 {
return nil, cloneSourceInfos(state.sources), prependCompositionDiagnosticPaths(failures, destination.Index(0))
}
if versionState != nil {
protectionRule := versionState.localRule
if versionState.mode == sourceVersionModeDefault && protectionRule != nil {
prefix := destination.Index(0)
relative, inside := relativeHistoryPath(prefix, protectionRule.Path)
if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) {
protectionRule = nil
} else {
scoped := *protectionRule
scoped.Path = relative
protectionRule = &scoped
}
}
if diagnostic := importedVersionClaim(documents, completed, protectionRule, 0); diagnostic != nil {
if rule != nil {
diagnostic.Path = rule.Path.clone()
}
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
}
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
projectNestedEvidence(completed, localScope)
_, placed, err := placeDocumentWithMode(completed.root, destination, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
}
placementNodes := destinationWrapperNodeCountForMode(destination, placementModeSequenceItem)
if accounting.nodes+placementNodes > limits.nodes {
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
}
accounting.nodes += placementNodes
composed, failures := composeDocumentsWithPolicy(policy, placed)
if composed != nil { if composed != nil {
synthetic := acquisitionScope{contributions: []acquiredContribution{{
source: cloneSourceInfo(mainInfo), payload: completed.root, document: placed,
destination: destination.clone(), processing: processing, nested: completed,
}}}
projectNestedEvidence(composed, synthetic)
projectAcquiredDestinations(composed, synthetic, state.sources)
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
composed.inputBytes = accounting.bytes
composed.inputNodes = accounting.nodes
}
return composed, cloneSourceInfos(state.sources), failures
}
documents := make([]*document.Node, 0, len(state.scope.contributions))
for _, contribution := range state.scope.contributions {
documents = append(documents, contribution.document)
}
composed, diagnostics := composeDocumentsWithPolicy(policy, documents...)
if composed != nil {
if diagnostic := importedVersionClaim(documents, composed, rule, 0); diagnostic != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
}
if composed != nil {
projectNestedEvidence(composed, state.scope)
projectAcquiredDestinations(composed, state.scope, state.sources)
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)}
composed.inputBytes = accounting.bytes composed.inputBytes = accounting.bytes
composed.inputNodes = accounting.nodes composed.inputNodes = accounting.nodes
} }
@@ -175,8 +313,9 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
return nil return nil
} }
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic { func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int, parentDestination Path) []Diagnostic {
for _, declaration := range declarations { for _, declaration := range declarations {
childDestination := appendDestination(parentDestination, declaration.destination)
if declaration.glob != "" { if declaration.glob != "" {
matches, diagnostics := s.expandImportGlob(basePath, declaration) matches, diagnostics := s.expandImportGlob(basePath, declaration)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -193,21 +332,29 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
matched := declaration matched := declaration
matched.path = match matched.path = match
matched.glob = "" matched.glob = ""
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 { if diagnostics := s.visitPath(basePath, matched, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 {
return diagnostics return diagnostics
} }
} }
continue continue
} }
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 { if diagnostics := s.visitPath(basePath, declaration, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 {
return diagnostics return diagnostics
} }
} }
return nil return nil
} }
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic { func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination, relativeDestination Path, depth int) []Diagnostic {
location := declaration.location location := declaration.location
if declaration.placement == placementModeSequenceItem && len(destination.segments) == 0 {
return []Diagnostic{{
Code: CodeImportInvalid, Severity: SeverityError,
Path: declaration.placementPath.clone(),
Message: "sequence-item placement requires a nonempty destination",
Primary: cloneLocation(declaration.placementLocation),
}}
}
if depth > s.limits.depth { if depth > s.limits.depth {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)} return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
} }
@@ -260,7 +407,14 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
kind = SourceKindFilesystem kind = SourceKindFilesystem
label = s.access.label + "/" + canonical label = s.access.label + "/" + canonical
} }
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Layer: LayerBase, Kind: kind, ImportedFrom: &location} info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
if declaration.placement == placementModeSequenceItem {
info.placementMode = placementModeSequenceItem
}
// A source's own item placement still has an exact sequence-container
// destination. Only an unresolved enclosing item index makes the path a
// fallback container for this snapshot.
info.destinationUnresolved = s.itemScope
if kind == SourceKindFilesystem { if kind == SourceKindFilesystem {
info.namespace = s.access.token info.namespace = s.access.token
} }
@@ -287,28 +441,338 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
} }
s.accounting.nodes += nodeCount s.accounting.nodes += nodeCount
body, declarations, diagnostics := splitImportDirectives(root) var body *document.Node
var declarations []importDeclaration
var diagnostics []Diagnostic
var processing *sourceVersionProcessingResult
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
versionDestination := destination
if s.itemScope {
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
}
processed, failures := s.versionState.process(root, sourceVersionContribution, versionDestination, sourceID, declaration.placement)
if len(failures) != 0 {
return failures
}
processing = &processed
body, declarations = processed.payload, processed.imports
} else {
body, declarations, diagnostics = splitImportDirectives(root)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return diagnostics return diagnostics
} }
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
versionLocation := publicLocation(versionNode.Location)
return []Diagnostic{{
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
}}
} }
placementDestination := destination
mode := placementModeMapping
if declaration.placement == placementModeSequenceItem {
mode = placementModeSequenceItem
}
if mode == placementModeSequenceItem {
route := importRoute{canonical: canonical, locations: currentRoute.locations} route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.documents = append(s.documents, body)
s.active[identity] = len(s.stack) s.active[identity] = len(s.stack)
s.visited[identity] = route s.visited[identity] = route
s.stack = append(s.stack, route) s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth) diagnostics := s.visitItemSource(canonical, declaration, destination, relativeDestination, depth, info, body, declarations, processing)
delete(s.active, identity) delete(s.active, identity)
s.stack = s.stack[:len(s.stack)-1] s.stack = s.stack[:len(s.stack)-1]
return diagnostics return diagnostics
} }
_, placed, err := placeDocument(body, placementDestination, body.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
}
placementNodes := destinationWrapperNodeCount(placementDestination)
if s.accounting.nodes+placementNodes > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.accounting.nodes += placementNodes
route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
source: cloneSourceInfo(info), payload: body, document: placed,
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
})
s.active[identity] = len(s.stack)
s.visited[identity] = route
s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth, destination)
delete(s.active, identity)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
func (s *importTraversal) sourceDestination(destination Path) Path {
if !s.itemScope {
return destination.clone()
}
// Until the enclosing item receives its assigned index, only the collection
// container is exact. Keep the local suffix in the acquisition projection.
return s.itemFallback.clone()
}
func (s *importTraversal) visitItemSource(canonical string, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic {
parentScope := s.scope
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
s.scope = acquisitionScope{}
s.itemScope = true
if parentItemScope {
s.itemPrefix = appendDestination(parentPrefix, destination)
} else {
s.itemPrefix = destination.clone()
s.itemFallback = destination.clone()
}
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
source: cloneSourceInfo(info), payload: body, document: body,
processing: processing,
})
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
itemScope := s.scope
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
}
documents := make([]*document.Node, 0, len(itemScope.contributions))
for _, contribution := range itemScope.contributions {
documents = append(documents, contribution.document)
}
completed, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 {
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
}
itemScope.destination = Path{}
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(itemScope)}
projectNestedEvidence(completed, itemScope)
pl := destination
_, placed, err := placeDocumentWithMode(completed.root, pl, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &declaration.location)}
}
placementNodes := destinationWrapperNodeCountForMode(pl, placementModeSequenceItem)
if s.accounting.nodes+placementNodes > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &declaration.location)}
}
s.accounting.nodes += placementNodes
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
source: cloneSourceInfo(info), payload: completed.root, document: placed,
destination: pl.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
nested: completed,
})
return nil
}
func importedVersionClaim(documents []*document.Node, composed *composedDocument, rule *VersionRule, mainIndex int) *Diagnostic {
if composed == nil || rule == nil {
return nil
}
ranges := indexSequenceRanges(composed.sequenceRanges)
for index, contribution := range documents {
if index == mainIndex {
continue
}
if node := fragmentVersionClaimWithIndex(contribution, rule, index, ranges); node != nil {
location := publicLocation(node.Location)
return &Diagnostic{
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
}
}
}
return nil
}
func prependCompositionDiagnosticPaths(diagnostics []Diagnostic, prefix Path) []Diagnostic {
cloned := append([]Diagnostic(nil), diagnostics...)
for index := range cloned {
if !isCompositionDiagnostic(cloned[index]) {
continue
}
cloned[index].Path = appendDestination(prefix, cloned[index].Path)
cloned[index].Primary = cloneLocation(cloned[index].Primary)
cloned[index].Related = cloneLocations(cloned[index].Related)
}
return cloned
}
func isCompositionDiagnostic(diagnostic Diagnostic) bool {
return diagnostic.Code == CodeComposeConflict || diagnostic.Code == CodeComposeInvalid
}
// On a composition failure only the earlier contributions are needed to locate
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
// and access diagnostics keep their original source-local coordinates.
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path) []Diagnostic {
for _, diagnostic := range diagnostics {
if isCompositionDiagnostic(diagnostic) {
index := priorSequenceLength([]acquisitionScope{parent}, destination)
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
}
}
return diagnostics
}
// This scan is confined to failed loads, not the successful construction path.
func priorSequenceLength(scopes []acquisitionScope, path Path) int {
length := 0
for _, scope := range scopes {
for _, contribution := range scope.contributions {
if node, present := documentAtPath(contribution.document, path); present && node.Kind == document.Sequence {
length += len(node.Sequence)
}
}
}
return length
}
func projectPriorInputDiagnosticPaths(diagnostics []Diagnostic, scopes []acquisitionScope) []Diagnostic {
cloned := cloneDiagnostics(diagnostics)
for index := range cloned {
if !isCompositionDiagnostic(cloned[index]) {
continue
}
for position, segment := range cloned[index].Path.segments {
if segment.kind != pathSegmentIndex {
continue
}
container := Path{segments: cloned[index].Path.segments[:position]}
cloned[index].Path.segments[position].index += priorSequenceLength(scopes, container)
// Registration does not address paths through item indexes. A prior
// input shifts the outer collection, not its item-local sequences.
break
}
}
return cloned
}
func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
if composed == nil {
return
}
applications := composed.applications
projectedApplications := make([]historyApplication, 0, len(applications))
ranges := indexSequenceRanges(composed.sequenceRanges)
for sourceIndex, contribution := range scope.contributions {
if contribution.nested == nil {
projectedApplications = append(projectedApplications, applications[sourceIndex])
continue
}
start := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}].start
prefix := contribution.destination.Index(start)
if sourceIndex < len(applications) {
outer := applications[sourceIndex]
structural := document.Clone(outer.value)
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > 0 {
// The retained fragment is still a local singleton. Its assigned
// outer index belongs to the projection, not this source tree.
node.Sequence[0] = &document.Node{Kind: document.Mapping, Location: node.Sequence[0].Location}
}
outer.value = structural
projectedApplications = append(projectedApplications, outer)
for _, nested := range contribution.nested.applications {
projected := nested
projected.path = appendDestination(prefix, nested.path)
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
projected.sequencesByPath = indexSequenceHistoryContributions(projected.sequences)
projectedApplications = append(projectedApplications, projected)
}
}
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
}
composed.applications = projectedApplications
}
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
var visit func(*document.Node, Path)
visit = func(node *document.Node, path Path) {
if node == nil {
return
}
if origin, ok := origins[path.String()]; ok {
target[appendDestination(prefix, path).String()] = cloneOrigin(origin)
}
for _, entry := range node.Mapping {
visit(entry.Value, path.Key(entry.Key))
}
for index, child := range node.Sequence {
visit(child, path.Index(index))
}
}
visit(root, Path{})
}
func projectAcquiredDestinations(composed *composedDocument, scope acquisitionScope, sources []SourceInfo) {
byID := make(map[string]int, len(sources))
for index := range sources {
byID[sources[index].ID] = index
}
var projectScope func(*composedDocument, acquisitionScope, Path, bool, string)
projectScope = func(currentComposition *composedDocument, current acquisitionScope, prefix Path, insideItem bool, parentSourceID string) {
ranges := indexSequenceRanges(currentComposition.sequenceRanges)
for sourceIndex, contribution := range current.contributions {
if contribution.source.ID == parentSourceID {
continue
}
contributionPrefix := prefix
contributionInsideItem := insideItem
start := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}].start
if contribution.source.placementMode == placementModeSequenceItem {
container := appendDestination(contributionPrefix, contribution.destination)
contribution.source.Destination = container.clone()
contribution.source.destinationUnresolved = false
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
if position, ok := byID[contribution.source.ID]; ok {
sources[position].Destination = container
sources[position].destinationUnresolved = false
}
contributionPrefix = appendDestination(container, Path{}.Index(start))
contributionInsideItem = true
projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex)
} else if contributionInsideItem {
exactDestination := appendDestination(contributionPrefix, contribution.destination)
contribution.source.Destination = exactDestination.clone()
contribution.source.destinationUnresolved = false
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
if position, ok := byID[contribution.source.ID]; ok {
sources[position].Destination = exactDestination
sources[position].destinationUnresolved = false
}
projectSourceVersionEvidence(contribution.processing, exactDestination, nil, sourceIndex)
} else {
projectSourceVersionEvidence(contribution.processing, contribution.destination, ranges, sourceIndex)
}
if contribution.nested != nil && contribution.nested.acquisition != nil {
for _, nestedScope := range contribution.nested.acquisition {
projectScope(contribution.nested, nestedScope, contributionPrefix, contributionInsideItem, contribution.source.ID)
}
}
}
}
projectScope(composed, scope, Path{}, false, "")
}
func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path, ranges sequenceRangeIndex, sourceIndex int) {
if processing != nil && processing.evidence != nil {
evidence := processing.evidence
evidence.destination = destination.clone()
// Rebuild from source-local paths on each handoff: an input can be
// composed first on its own and then at a different offset in its tier.
evidence.projectedPaths = nil
paths := evidence.sensitivityPaths()
for index, path := range paths {
for position := range path.segments {
prefix := Path{segments: path.segments[:position]}
assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: prefix.String()}]
if !found {
continue
}
item, ok := schemaIndex(path.segments[position])
if ok && path.segments[position].kind != pathSegmentKey {
path.segments[position] = pathSegment{kind: pathSegmentIndex, index: assigned.start + item}
}
}
paths[index] = path
}
evidence.projectedPaths = paths
}
}
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) { func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
remaining := s.limits.bytes - s.accounting.bytes remaining := s.limits.bytes - s.accounting.bytes

View File

@@ -9,6 +9,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
type importTestConfig struct { type importTestConfig struct {
@@ -155,6 +157,8 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid}, {name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid}, {name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid}, {name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
{name: "placement kind", body: "configkit: {imports: [{path: a.yml, placement: true}]}\n", code: CodeImportInvalid},
{name: "placement value", body: "configkit: {imports: [{path: a.yml, placement: item}]}\n", code: CodeImportInvalid},
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid}, {name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
} }
for _, test := range tests { for _, test := range tests {
@@ -166,6 +170,434 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
} }
} }
func TestLoadItemImportComposesHelpersAndNestedItemsInDeclaringScope(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type record struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Label string `yaml:"label"`
Metadata map[string]string `yaml:"metadata"`
Children []nested `yaml:"children"`
}
type config struct {
Items []record `yaml:"items"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: records/item.yml
destination: [items]
placement: sequence-item
`)
itemPath := writeImportFile(t, directory, "records/item.yml", `configkit:
imports:
- path: nested/helper.yml
- path: child.yml
destination: [children]
placement: sequence-item
name: authored
`)
helperPath := writeImportFile(t, directory, "records/nested/helper.yml", `configkit:
imports:
- path: more.yml
destination: [metadata]
id: supplied
label: helper
`)
morePath := writeImportFile(t, directory, "records/nested/more.yml", "flavor: transitive\n")
childPath := writeImportFile(t, directory, "records/child.yml", "value: nested\n")
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
if err != nil {
t.Fatalf("load item import: %v", err)
}
if len(result.Config.Items) != 1 {
t.Fatalf("items = %#v", result.Config.Items)
}
item := result.Config.Items[0]
if item.ID != "supplied" || item.Name != "authored" || item.Label != "helper" || item.Metadata["flavor"] != "transitive" || len(item.Children) != 1 || item.Children[0].Value != "nested" {
t.Fatalf("composed item = %#v", item)
}
if got := result.Sources(); len(got) != 5 || got[1].Path != canonicalTestPath(t, itemPath) || got[2].Path != canonicalTestPath(t, helperPath) || got[3].Path != canonicalTestPath(t, morePath) || got[4].Path != canonicalTestPath(t, childPath) {
t.Fatalf("source traversal = %#v", got)
}
for _, test := range []struct {
index int
want string
}{
{index: 1, want: "/items"},
{index: 2, want: "/items/0"},
{index: 3, want: "/items/0/metadata"},
{index: 4, want: "/items/0/children"},
} {
if got := result.Sources()[test.index].Destination.String(); got != test.want {
t.Errorf("source %d destination = %q, want %q", test.index+1, got, test.want)
}
}
for _, test := range []struct {
path Path
sourceID string
}{
{path: Keys("items").Index(0).Key("id"), sourceID: "source-3"},
{path: Keys("items").Index(0).Key("metadata").Key("flavor"), sourceID: "source-4"},
} {
info, ok := result.Metadata().Lookup(test.path)
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != test.sourceID {
t.Errorf("origin at %s = %#v, %v, want source %s", test.path.String(), info, ok, test.sourceID)
}
foundInHistory := false
for _, change := range result.Metadata().History(test.path) {
for _, location := range change.Origin.Locations {
foundInHistory = foundInHistory || location.SourceID == test.sourceID
}
}
if !foundInHistory {
t.Errorf("history at %s does not retain source %s", test.path.String(), test.sourceID)
}
}
t.Run("duplicate helper identity remains a mapping conflict", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: item.yml
destination: [items]
placement: sequence-item
`)
writeImportFile(t, directory, "item.yml", `configkit:
imports:
- path: helper.yml
id: authored
`)
writeImportFile(t, directory, "helper.yml", "id: supplied\n")
_, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
t.Fatalf("duplicate identity error = %v", err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || len(diagnostic.Related) != 1 {
t.Fatalf("duplicate identity diagnostic = %#v", diagnostic)
}
})
}
func TestLayeredUnderlayItemImportRetainsNestedEvidenceAndDestinations(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type record struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Label string `yaml:"label"`
Metadata map[string]string `yaml:"metadata"`
Children []nested `yaml:"children"`
}
type config struct {
Catalog struct {
Entries []record `yaml:"entries"`
} `yaml:"catalog"`
}
directory := t.TempDir()
itemPath := writeImportFile(t, directory, "records/item.yml", `configkit:
imports:
- path: nested/helper.yml
- path: children/child.yml
destination: [children]
placement: sequence-item
name: authored
`)
helperPath := writeImportFile(t, directory, "records/nested/helper.yml", `configkit:
imports:
- path: more.yml
destination: [metadata]
id: supplied
label: helper
`)
morePath := writeImportFile(t, directory, "records/nested/more.yml", "flavor: transitive\n")
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
loaded, err := LoadLayered(
Input{Source: NoSource()}, testSecretBinding(),
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
}}}}},
)
if err != nil {
if loadError, ok := err.(*LoadError); ok {
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, loadError.Diagnostics())
}
t.Fatalf("LoadLayered: %v", err)
}
result := testLoaded(loaded)
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0].ID != "supplied" || result.Config.Catalog.Entries[0].Name != "authored" ||
result.Config.Catalog.Entries[0].Label != "helper" || result.Config.Catalog.Entries[0].Metadata["flavor"] != "transitive" ||
len(result.Config.Catalog.Entries[0].Children) != 1 || result.Config.Catalog.Entries[0].Children[0].Value != "nested" {
t.Fatalf("underlay item = %#v", result.Config.Catalog.Entries)
}
sources := result.Sources()
wantSources := []struct {
path string
destination string
}{
{path: itemPath, destination: "/catalog/entries"},
{path: helperPath, destination: "/catalog/entries/0"},
{path: morePath, destination: "/catalog/entries/0/metadata"},
{path: childPath, destination: "/catalog/entries/0/children"},
}
if len(sources) != len(wantSources) {
t.Fatalf("sources = %#v", sources)
}
for index, want := range wantSources {
if sources[index].Path != canonicalTestPath(t, want.path) || sources[index].Destination.String() != want.destination {
t.Errorf("source %d = %#v, want path %q and destination %q", index+1, sources[index], want.path, want.destination)
}
}
for _, test := range []struct {
path Path
sourceID string
}{
{path: Keys("catalog", "entries").Index(0).Key("id"), sourceID: "source-2"},
{path: Keys("catalog", "entries").Index(0).Key("metadata").Key("flavor"), sourceID: "source-3"},
{path: Keys("catalog", "entries").Index(0).Key("children").Index(0).Key("value"), sourceID: "source-4"},
} {
info, ok := result.Metadata().Lookup(test.path)
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != test.sourceID || info.Origin.Layer != LayerUnderlay {
t.Errorf("origin at %s = %#v, %v, want %s in underlay", test.path.String(), info, ok, test.sourceID)
}
foundInHistory := false
for _, change := range result.Metadata().History(test.path) {
for _, location := range change.Origin.Locations {
foundInHistory = foundInHistory || location.SourceID == test.sourceID
}
}
if !foundInHistory {
t.Errorf("history at %s does not retain source %s", test.path.String(), test.sourceID)
}
}
}
func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testing.T) {
t.Parallel()
type item struct {
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
Other map[string]string `yaml:"other"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: records/*.yml
destination: [items]
placement: sequence-item
- path: absent/*.yml
destination: [items]
optional: true
placement: sequence-item
- path: other.yml
destination: [other]
`)
emptyPath := writeImportFile(t, directory, "records/empty.yml", "{}\n")
writeImportFile(t, directory, "other.yml", "value: mapping-default\n")
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
if err != nil {
t.Fatalf("load item glob: %v", err)
}
if len(result.Config.Items) != 1 || result.Config.Items[0].Value != "" || result.Config.Other["value"] != "mapping-default" {
t.Fatalf("config = %#v", result.Config)
}
sources := result.Sources()
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != placementModeSequenceItem || sources[2].placementMode != placementModeMapping || sources[2].Destination.String() != "/other" {
t.Fatalf("source placement evidence = %#v", sources)
}
}
func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
result, err := testLoad[config](File(mainPath), Options[config]{
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
})
if err != nil {
t.Fatalf("load main item: %v", err)
}
if len(result.Config.Items) != 1 || result.Config.Items[0].ID != "main" || result.Config.Items[0].Value != "helper" {
t.Fatalf("main item = %#v", result.Config.Items)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != placementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != placementModeMapping {
t.Fatalf("main item sources = %#v", sources)
}
if sources[1].Path != canonicalTestPath(t, helperPath) {
t.Fatalf("helper path = %q", sources[1].Path)
}
}
func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) {
t.Parallel()
type config struct {
Items []map[string]string `yaml:"items"`
}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: item.yml
destination: [items]
placement: sequence-item
`)
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
input, diagnostics := assembleBaseInput(File(mainPath), Options[config]{Imports: true})
if len(diagnostics) != 0 {
t.Fatalf("assemble item import: %#v", diagnostics)
}
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy{identityFields: map[string]string{"/items": "id"}}, authored, input.document)
if len(diagnostics) != 0 {
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
}
items, ok := documentAtPath(composed.root, Keys("items"))
if !ok || items.Kind != document.Sequence || len(items.Sequence) != 2 {
t.Fatalf("composed items = %#v", items)
}
if id, ok := documentAtPath(composed.root, Keys("items").Index(1).Key("id")); !ok || id.Text != "helper-id" {
t.Fatalf("imported helper ID = %#v, %v", id, ok)
}
if origin := composed.origins[Keys("items").Index(1).Key("id").String()]; len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-3" {
t.Fatalf("registered composition origin = %#v", origin)
}
}
func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
destination string
want Path
}{
{name: "omitted"},
{name: "empty sequence", destination: " destination: []\n"},
{
name: "literal keys",
destination: " destination: ['', '.', '..', 'a/b', '~', '0', configkit2, myextends]\n",
want: Keys("", ".", "..", "a/b", "~", "0", "configkit2", "myextends"),
},
} {
t.Run(test.name, func(t *testing.T) {
body := "configkit:\n imports:\n - path: child.yml\n" + test.destination
declarations := parseImportDeclarationsYAML(t, body)
if len(declarations) != 1 {
t.Fatalf("declarations = %#v", declarations)
}
got := declarations[0].destination
if got.String() != test.want.String() {
t.Fatalf("destination = %q, want %q", got.String(), test.want.String())
}
for index, segment := range got.segments {
if segment.kind != pathSegmentKey {
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
}
}
})
}
}
func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
t.Parallel()
declarations := parseImportDeclarationsYAML(t, `configkit:
imports:
- path: mapping.yml
- glob: records/*.yml
placement: sequence-item
`)
if len(declarations) != 2 || declarations[0].placement != placementModeMapping || declarations[1].placement != placementModeSequenceItem {
t.Fatalf("declaration placements = %#v", declarations)
}
}
func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
value string
wantPath string
badMember string
}{
{name: "null", value: "null", wantPath: "/configkit/imports/0/destination", badMember: "null"},
{name: "scalar string", value: "prompts", wantPath: "/configkit/imports/0/destination", badMember: "prompts"},
{name: "number", value: "3", wantPath: "/configkit/imports/0/destination", badMember: "3"},
{name: "non-string number", value: "[valid, 7]", wantPath: "/configkit/imports/0/destination/1", badMember: "7"},
{name: "non-string null", value: "[valid, null]", wantPath: "/configkit/imports/0/destination/1", badMember: "null"},
{name: "non-string boolean", value: "[valid, true]", wantPath: "/configkit/imports/0/destination/1", badMember: "true"},
{name: "non-string mapping", value: "[valid, {key: value}]", wantPath: "/configkit/imports/0/destination/1", badMember: "{key"},
{name: "reserved configkit", value: "[valid, configkit]", wantPath: "/configkit/imports/0/destination/1", badMember: "configkit"},
{name: "reserved extends", value: "[valid, extends]", wantPath: "/configkit/imports/0/destination/1", badMember: "extends"},
} {
t.Run(test.name, func(t *testing.T) {
body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n"
parsed, diagnostics := parseImportDeclarationYAML(body)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath {
t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath)
}
line := strings.Split(body, "\n")[3]
wantColumn := strings.Index(line, test.badMember) + 1
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" || diagnostics[0].Primary.Line != 4 || diagnostics[0].Primary.Column != wantColumn {
t.Fatalf("invalid destination location = %#v, want line 4 column %d (parsed %v)", diagnostics[0].Primary, wantColumn, parsed)
}
})
}
}
func TestImportDestinationValidationPrecedesOptionalSuppression(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
body string
path string
}{
{
name: "optional missing path",
body: "configkit:\n imports:\n - path: absent.yml\n optional: true\n destination: null\n",
path: "/configkit/imports/0/destination",
},
{
name: "optional unmatched glob",
body: "configkit:\n imports:\n - glob: absent/*.yml\n optional: true\n destination: [extends]\n",
path: "/configkit/imports/0/destination/0",
},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", test.body)
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostic path = %q, want %q", diagnostics[0].Path.String(), test.path)
}
})
}
}
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) { func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
t.Run("optional missing", func(t *testing.T) { t.Run("optional missing", func(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
@@ -486,3 +918,21 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
} }
return parsed return parsed
} }
func parseImportDeclarationsYAML(t *testing.T, input string) []importDeclaration {
t.Helper()
declarations, diagnostics := parseImportDeclarationYAML(input)
if len(diagnostics) != 0 {
t.Fatalf("import parse diagnostics = %#v", diagnostics)
}
return declarations
}
func parseImportDeclarationYAML(input string) ([]importDeclaration, []Diagnostic) {
parsed, diagnostics := parseSource(Bytes("import fixture", []byte(input)), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
return nil, diagnostics
}
_, declarations, diagnostics := splitImportDirectives(parsed.document)
return declarations, diagnostics
}

View File

@@ -41,7 +41,7 @@ func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope)
} }
} else if operation.transfer != nil { } else if operation.transfer != nil {
if relative, within := relativeHistoryPath(operation.transfer.to, rule.Path); within { if relative, within := relativeHistoryPath(operation.transfer.to, rule.Path); within {
_, affected = documentAtPath(operation.parent, relative) affected = fragmentVersionClaim(operation.parent, &VersionRule{Path: relative}) != nil
} else if pathContains(rule.Path, operation.transfer.to) { } else if pathContains(rule.Path, operation.transfer.to) {
affected = true affected = true
} }

View File

@@ -92,6 +92,40 @@ func TestInheritanceSensitivityIncludesSchemaTaggedSource(t *testing.T) {
} }
} }
func TestSourceVersionEvidenceSensitivityFollowsInheritanceAndDisplacedHistory(t *testing.T) {
const canary = "EVIDENCE-INHERITANCE-CANARY"
input := parseCompositionDocument(t, "source-1", "parent: {branch: {token: "+canary+", plain: visible}}\nchild: {extends: parent, branch: public}\n")
inspection, _ := inheritanceInspectionForTest(t, input)
secretPath := Keys("parent", "branch", "token")
evidence := &sourceVersionEvidence{
path: secretPath.clone(), resolvedPath: secretPath.clone(),
version: Version{Present: true, Text: canary},
ancestors: []sourceVersionAncestor{{}, {path: Keys("parent")}, {path: Keys("parent", "branch")}, {path: secretPath.clone()}},
}
inspection.secrets = classifySecretPathsWithEvidence(nil, func(path Path) bool {
return path.String() == secretPath.String()
}, inspection.metadata, inspection.final, inspection.applications, []*sourceVersionEvidence{evidence}, inspection.input)
path := Keys("child", "branch", "token")
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.Present {
t.Fatalf("displaced inherited path = %#v, %v", explanation, found)
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) ||
(change.After.Present && !change.After.Redacted) {
t.Fatalf("displaced inherited canary escaped redaction: %#v", explanation.History)
}
}
plain, found := inspection.Explain(Keys("parent", "branch", "plain"))
if !found || plain.Effective.Redacted || plain.Effective.YAML != "visible\n" {
t.Fatalf("neighboring value was classified with evidence: %#v, %v", plain, found)
}
if _, found := inspection.Metadata().Lookup(Keys("evidence-only")); found {
t.Fatal("evidence classification created a metadata path")
}
}
func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) { func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) {
type entry struct { type entry struct {
Credential Secret `yaml:"credential"` Credential Secret `yaml:"credential"`

View File

@@ -2,11 +2,13 @@ package engine
import "os" import "os"
// Input selects one source and its independent import policy. // Input selects one source, its destination, and its independent import policy.
type Input struct { type Input struct {
Source Source Source Source
Imports bool Imports bool
Filesystem *FilesystemContext Filesystem *FilesystemContext
Destination Path
placementMode placementMode
} }
// Underlay contains strictly composed contributions at one precedence tier. // Underlay contains strictly composed contributions at one precedence tier.
@@ -15,6 +17,7 @@ type Underlay struct{ Inputs []Input }
// LayeredOptions selects lower-priority tiers and final application rules. // LayeredOptions selects lower-priority tiers and final application rules.
type LayeredOptions[T any] struct { type LayeredOptions[T any] struct {
Definition Definition[T] Definition Definition[T]
SourceVersionMode sourceVersionMode
Underlays []Underlay Underlays []Underlay
Overlay string Overlay string
OverlayFilesystem *FilesystemContext OverlayFilesystem *FilesystemContext
@@ -32,8 +35,12 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
options = supplied[0] options = supplied[0]
} }
request := loadRequest[T]{ request := loadRequest[T]{
main: loadInput{source: main.Source, imports: main.Imports, filesystem: main.Filesystem}, main: loadInput{
definition: options.Definition, overlay: options.Overlay, source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
destination: main.Destination.clone(), placementMode: main.placementMode,
},
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
overlay: options.Overlay,
overlayFilesystem: options.OverlayFilesystem, overlayFilesystem: options.OverlayFilesystem,
overrides: append([]Override(nil), options.Overrides...), overrides: append([]Override(nil), options.Overrides...),
underlays: make([]loadGroup, len(options.Underlays)), underlays: make([]loadGroup, len(options.Underlays)),
@@ -43,6 +50,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
for inputIndex, input := range underlay.Inputs { for inputIndex, input := range underlay.Inputs {
request.underlays[index].inputs[inputIndex] = loadInput{ request.underlays[index].inputs[inputIndex] = loadInput{
source: input.Source, imports: input.Imports, filesystem: input.Filesystem, source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
destination: input.Destination.clone(), placementMode: input.placementMode,
} }
} }
} }

View File

@@ -16,6 +16,8 @@ type Definition[T any] struct {
Validate func(*T, *Validation) Validate func(*T, *Validation)
Version *VersionRule Version *VersionRule
Secret func(Path) bool Secret func(Path) bool
sequenceRegistrations []sequenceRegistration
} }
// Options contains the application definition and per-load input selections. // Options contains the application definition and per-load input selections.
@@ -27,6 +29,10 @@ type Options[T any] struct {
Overlay string Overlay string
Filesystem *FilesystemContext Filesystem *FilesystemContext
Overrides []Override Overrides []Override
SourceVersionMode sourceVersionMode
destination Path
placementMode placementMode
sequencePolicy sequencePolicy
} }
// FilesystemContext supplies filesystem roots for imports and overlays. // FilesystemContext supplies filesystem roots for imports and overlays.
@@ -136,13 +142,19 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) { func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
options, diagnostics := selectOptions(source, supplied) options, diagnostics := selectOptions(source, supplied)
policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations)
diagnostics = append(diagnostics, policyDiagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError() return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
} }
definition := options.Definition
definition.sequenceRegistrations = nil
request := loadRequest[T]{ request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem}, main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
definition: options.Definition, overlay: options.Overlay, definition: definition, sequencePolicy: policy, overlay: options.Overlay,
overrides: options.Overrides, ordinary: true, overrides: options.Overrides, ordinary: true,
sourceVersionMode: options.SourceVersionMode,
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
} }
return executeLoadRequest(request, secrets, lookup) return executeLoadRequest(request, secrets, lookup)
} }
@@ -150,32 +162,65 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) { func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
accumulator := diagnosticAccumulator{} accumulator := diagnosticAccumulator{}
accounting := &importAccounting{} accounting := &importAccounting{}
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem} mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
mainOptions.sequencePolicy = request.sequencePolicy
mainOptions.SourceVersionMode = request.sourceVersionMode
mainOptions.Definition.Version = request.protectionVersion()
mainOptions.Overrides = request.overrides
if request.ordinary { if request.ordinary {
mainOptions.Overlay = request.overlay mainOptions.Overlay = request.overlay
} }
input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, accounting, nil, nil) var schema *schemaDescriptor
accumulator.sources = input.sources if request.sourceVersionMode != sourceVersionModeDefault {
accumulator.add(diagnostics...) var schemaIssue *schemaIssue
if len(diagnostics) != 0 { schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
return nil, accumulator.loadError()
}
schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil { if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
}
versionState := &sourceVersionState{
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
}
accumulator.versionState = versionState
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
accumulator.sources = input.sources
accumulator.add(versionState.diagnostics...)
reportedVersionDiagnostics := len(versionState.diagnostics)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
if schema == nil {
var schemaIssue *schemaIssue
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError()
}
}
if request.sourceVersionMode == sourceVersionModeDefault {
versionDiagnostics := checkVersion(request.definition.Version, input.document) versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...) accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) { if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
}
if request.ordinary && input.document != nil {
input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
input.document = input.composition.root
}
if request.ordinary { if request.ordinary {
input, diagnostics = applyInputLayers(request.main.source, Options[T]{Definition: request.definition, Overlay: request.overlay, Filesystem: request.main.filesystem, Overrides: request.overrides}, input) input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
} else { } else {
input, diagnostics = assembleLayeredInput(request, input, accounting) input, diagnostics = assembleLayeredInput(request, input, accounting)
} }
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
accumulator.sources = input.sources accumulator.sources = input.sources
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -208,7 +253,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
accumulator.add(problems...) accumulator.add(problems...)
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil { if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil {
accumulator.add(*problem) accumulator.add(*problem)
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
@@ -217,7 +262,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
input.document = expanded.root input.document = expanded.root
} }
decoded, diagnostics := decodeLoadInput(schema, request.definition, input) decodeDefinition := request.definition
decodeDefinition.Version = request.protectionVersion()
decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, accumulator.loadError()

View File

@@ -11,33 +11,127 @@ type assembledInput struct {
parsed parsedSource parsed parsedSource
document *document.Node document *document.Node
composition *composedDocument composition *composedDocument
acquisition []acquisitionScope
inheritance *inheritanceScope inheritance *inheritanceScope
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext contexts map[string]ResourceContext
versionState *sourceVersionState
}
// acquiredContribution keeps a source-local payload beside its placed document.
// The latter is used for composition; the former preserves the acquisition
// boundary for later scoped assembly and evidence.
type acquiredContribution struct {
source SourceInfo
payload *document.Node
document *document.Node
destination Path
relativeDestination Path
processing *sourceVersionProcessingResult
nested *composedDocument
}
// acquisitionScope is the ordered strict mapping scope produced by one input
// and its ordinary imports.
type acquisitionScope struct {
destination Path
contributions []acquiredContribution
}
func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope {
cloned := acquisitionScope{destination: scope.destination.clone()}
if scope.contributions != nil {
cloned.contributions = make([]acquiredContribution, len(scope.contributions))
for index, contribution := range scope.contributions {
cloned.contributions[index] = acquiredContribution{
source: cloneSourceInfo(contribution.source), payload: contribution.payload,
document: contribution.document, destination: contribution.destination.clone(),
relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing,
nested: cloneComposedDocument(contribution.nested),
}
}
}
return cloned
}
func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
if scopes == nil {
return nil
}
cloned := make([]acquisitionScope, len(scopes))
for index, scope := range scopes {
cloned[index] = cloneAcquisitionScope(scope)
}
return cloned
} }
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
return assembleBaseInputTracked(source, options, &importAccounting{}, nil, nil) return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil)
} }
func assembleBaseInputTracked[T any](source Source, options Options[T], accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) { func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil)
}
func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (assembledInput, []Diagnostic) {
return assembleBaseInputTrackedForRole(source, options, destination, accounting, visited, hostToken, versionState, sourceVersionMain)
}
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
limits := defaultImportLimits() limits := defaultImportLimits()
parseLimits := defaultParseLimits() parseLimits := defaultParseLimits()
parseLimits.source.remaining = limits.bytes - accounting.bytes parseLimits.source.remaining = limits.bytes - accounting.bytes
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits) parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
input := assembledInput{parsed: parsed, document: parsed.document} if parsed.source != nil {
parsed.source.Destination = destination.clone()
parsed.source.placementMode = options.placementMode
}
input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState}
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
input.sources = sourceInfos(parsed.source) input.sources = sourceInfos(parsed.source)
// Mapping value issues already identify application content. Other early
// failures describe source syntax or access, not a placed value.
if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
for index := range diagnostics {
relative := diagnostics[index].Path
segments := clonePathSegments(destination.segments, len(relative.segments))
diagnostics[index].Path = Path{segments: append(segments, relative.segments...)}
}
}
return input, diagnostics return input, diagnostics
} }
if !options.Imports && parsed.document != nil {
if diagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}); len(diagnostics) != 0 {
return input, diagnostics
}
}
var versionProcessing *sourceVersionProcessingResult
if versionState != nil && versionState.mode != sourceVersionModeDefault {
var sourceID string
if parsed.source != nil {
sourceID = parsed.source.ID
}
processed, failures := versionState.process(parsed.document, role, destination, sourceID, options.placementMode)
if len(failures) != 0 {
return input, failures
}
versionProcessing = &processed
if role == sourceVersionMain {
versionState.mainProcessing = &processed
}
}
if options.Imports { if options.Imports {
input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, options.Definition.Version, limits, accounting, visited, hostToken) input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
input.document = input.composition.root input.document = input.composition.root
} else { } else {
input.sources = sourceInfos(parsed.source) input.sources = sourceInfos(parsed.source)
if versionProcessing != nil {
input.document = versionProcessing.payload
}
if options.Filesystem != nil && !activeOverlay(options.Overlay) { if options.Filesystem != nil && !activeOverlay(options.Overlay) {
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
return input, []Diagnostic{*diagnostic} return input, []Diagnostic{*diagnostic}
@@ -45,10 +139,22 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], accounti
} }
} }
if !options.Imports && input.document != nil { if !options.Imports && input.document != nil {
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 { _, placed, err := placeDocumentWithMode(input.document, destination, options.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
return input, diagnostics if err != nil {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} }
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+countInputNodes(input.document) > limits.nodes { localPayload := input.document
input.document = placed
input.acquisition = []acquisitionScope{{
destination: destination.clone(),
contributions: []acquiredContribution{{
source: cloneSourceInfo(*parsed.source), payload: localPayload,
document: placed, destination: destination.clone(), processing: versionProcessing,
}},
}}
rawNodes := countInputNodes(parsed.document)
placementNodes := destinationWrapperNodeCountForMode(destination, options.placementMode)
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")} return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
} }
if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil { if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
@@ -56,7 +162,7 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], accounti
} }
accounting.sources++ accounting.sources++
accounting.bytes += parsed.bytes accounting.bytes += parsed.bytes
accounting.nodes += countInputNodes(input.document) accounting.nodes += rawNodes + placementNodes
} }
if !activeOverlay(options.Overlay) || options.Imports { if !activeOverlay(options.Overlay) || options.Imports {
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources) contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
@@ -90,15 +196,15 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 { if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
return input, nil return input, nil
} }
composition, diagnostics := compositionForInput(input) composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
assembly := newAssemblyAccumulator(composition) assembly := newAssemblyAccumulator(composition)
if activeOverlay(options.Overlay) { if activeOverlay(options.Overlay) {
input.sources, diagnostics = loadSelectedOverlayInto( input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
source, options.Filesystem, input.parsed, assembly, input.sources, source, options.Filesystem, input.parsed, assembly, input.sources,
options.Overlay, options.Definition.Version, defaultImportLimits(), options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState,
) )
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
@@ -125,17 +231,22 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
// compositionForInput initializes one handoff for overlays and CLI, including // compositionForInput initializes one handoff for overlays and CLI, including
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets. // defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) { func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
return compositionForInputWithPolicy(input, sequencePolicy{})
}
func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy) (*composedDocument, []Diagnostic) {
if input.composition != nil { if input.composition != nil {
return input.composition, nil return input.composition, nil
} }
if input.document == nil { if input.document == nil {
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
} }
composition, diagnostics := composeDocuments(input.document) composition, diagnostics := composeDocumentsWithPolicy(policy, input.document)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
} }
composition.inputBytes = input.parsed.bytes composition.inputBytes = input.parsed.bytes
composition.inputNodes = countInputNodes(input.document) composition.inputNodes = countInputNodes(input.document)
composition.acquisition = cloneAcquisitionScopes(input.acquisition)
return composition, nil return composition, nil
} }

View File

@@ -10,16 +10,28 @@ type loadRequest[T any] struct {
main loadInput main loadInput
underlays []loadGroup underlays []loadGroup
definition Definition[T] definition Definition[T]
sequencePolicy sequencePolicy
sourceVersionMode sourceVersionMode
effectiveVersion *VersionRule
overlay string overlay string
overlayFilesystem *FilesystemContext overlayFilesystem *FilesystemContext
overrides []Override overrides []Override
ordinary bool ordinary bool
} }
func (request loadRequest[T]) protectionVersion() *VersionRule {
if request.effectiveVersion != nil {
return request.effectiveVersion
}
return request.definition.Version
}
type loadInput struct { type loadInput struct {
source Source source Source
imports bool imports bool
filesystem *FilesystemContext filesystem *FilesystemContext
destination Path
placementMode placementMode
} }
type loadGroup struct{ inputs []loadInput } type loadGroup struct{ inputs []loadInput }
@@ -27,13 +39,22 @@ type loadGroup struct{ inputs []loadInput }
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) { func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
request.ordinary = false request.ordinary = false
var diagnostics []Diagnostic var diagnostics []Diagnostic
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{ policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, request.sequencePolicy = policy
diagnostics = append(diagnostics, policyDiagnostics...)
request.definition.sequenceRegistrations = nil
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
}}) }})
request.main.destination = mainOptions.destination
diagnostics = append(diagnostics, mainDiagnostics...) diagnostics = append(diagnostics, mainDiagnostics...)
for _, group := range request.underlays { request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
for _, input := range group.inputs { for groupIndex := range request.underlays {
_, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem}}) for inputIndex := range request.underlays[groupIndex].inputs {
input := &request.underlays[groupIndex].inputs[inputIndex]
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode}})
input.destination = inputOptions.destination
diagnostics = append(diagnostics, inputDiagnostics...) diagnostics = append(diagnostics, inputDiagnostics...)
} }
} }
@@ -56,7 +77,7 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) { func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
groups := make([]*composedDocument, 0, len(request.underlays)) groups := make([]*composedDocument, 0, len(request.underlays))
for index, group := range request.underlays { for index, group := range request.underlays {
composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting) composition, sources, contexts, diagnostics := acquireUnderlayGroupWithPolicyVersion(group, index+1, request.protectionVersion(), accounting, input.versionState, request.sequencePolicy)
input.sources = append(input.sources, sources...) input.sources = append(input.sources, sources...)
mergeResourceContexts(&input.contexts, contexts) mergeResourceContexts(&input.contexts, contexts)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -78,7 +99,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
} }
} }
if input.document != nil { if input.document != nil {
mainComposition, diagnostics := compositionForInput(input) mainComposition, diagnostics := compositionForInputWithPolicy(input, request.sequencePolicy)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
@@ -103,7 +124,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
selection := Source{kind: sourceBytes} selection := Source{kind: sourceBytes}
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}} selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
var diagnostics []Diagnostic var diagnostics []Diagnostic
input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.definition.Version, defaultImportLimits()) input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
@@ -115,7 +136,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
} }
if len(request.overrides) != 0 { if len(request.overrides) != 0 {
var diagnostics []Diagnostic var diagnostics []Diagnostic
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.definition.Version, defaultImportLimits()) input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.protectionVersion(), defaultImportLimits())
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
@@ -126,15 +147,27 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
} }
func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) { func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
return acquireUnderlayGroupWithVersion(group, index, rule, accounting, nil)
}
func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
return acquireUnderlayGroupWithPolicyVersion(group, index, rule, accounting, versionState, sequencePolicy{})
}
func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState, policy sequencePolicy) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
initialBytes, initialNodes := accounting.bytes, accounting.nodes initialBytes, initialNodes := accounting.bytes, accounting.nodes
visited := make(map[resourceIdentity]importRoute) visited := make(map[resourceIdentity]importRoute)
hostToken := &namespaceToken{} hostToken := &namespaceToken{}
var documents []*document.Node var scopes []acquisitionScope
var sources []SourceInfo var sources []SourceInfo
var contexts map[string]ResourceContext var contexts map[string]ResourceContext
for _, contribution := range group.inputs { for _, contribution := range group.inputs {
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}} options := Options[struct{}]{
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, accounting, visited, hostToken) Imports: contribution.imports, Filesystem: contribution.filesystem,
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
placementMode: contribution.placementMode, sequencePolicy: policy,
}
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
for _, source := range acquired.sources { for _, source := range acquired.sources {
source.Layer = LayerUnderlay source.Layer = LayerUnderlay
source.UnderlayIndex = index source.UnderlayIndex = index
@@ -142,35 +175,47 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
} }
mergeResourceContexts(&contexts, acquired.contexts) mergeResourceContexts(&contexts, acquired.contexts)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
} }
if acquired.document == nil { if acquired.document == nil {
continue continue
} }
if node := fragmentVersionClaim(acquired.document, rule); node != nil { scopes = append(scopes, cloneAcquisitionScopes(acquired.acquisition)...)
}
var documents []*document.Node
for _, scope := range scopes {
for _, contribution := range scope.contributions {
documents = append(documents, contribution.document)
}
}
if len(documents) == 0 {
return nil, sources, contexts, nil
}
composition, diagnostics := composeDocumentsWithPolicy(policy, documents...)
if len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics
}
if composition != nil && rule != nil {
ranges := indexSequenceRanges(composition.sequenceRanges)
for sourceIndex, document := range documents {
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
location := publicLocation(node.Location) location := publicLocation(node.Location)
return nil, sources, contexts, []Diagnostic{{ return nil, sources, contexts, []Diagnostic{{
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(), Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location, Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location,
}} }}
} }
if acquired.composition != nil {
for _, application := range acquired.composition.applications {
documents = append(documents, application.value)
} }
} else {
documents = append(documents, acquired.document)
}
}
if len(documents) == 0 {
return nil, sources, contexts, nil
}
composition, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics
} }
composition.inputBytes = accounting.bytes - initialBytes composition.inputBytes = accounting.bytes - initialBytes
composition.inputNodes = accounting.nodes - initialNodes composition.inputNodes = accounting.nodes - initialNodes
composition.acquisition = cloneAcquisitionScopes(scopes)
var projectionScope acquisitionScope
for _, scope := range scopes {
projectionScope.contributions = append(projectionScope.contributions, scope.contributions...)
}
projectNestedEvidence(composition, projectionScope)
projectAcquiredDestinations(composition, projectionScope, sources)
for pointer, origin := range composition.origins { for pointer, origin := range composition.origins {
origin.Layer = LayerUnderlay origin.Layer = LayerUnderlay
composition.origins[pointer] = origin composition.origins[pointer] = origin

View File

@@ -15,6 +15,7 @@ type resolvedLoad[T any] struct {
final *document.Node final *document.Node
snapshots []resolutionSnapshot snapshots []resolutionSnapshot
applications []historyApplication applications []historyApplication
versionEvidence []*sourceVersionEvidence
inheritance *inheritanceScope inheritance *inheritanceScope
contexts map[string]ResourceContext contexts map[string]ResourceContext
} }
@@ -57,7 +58,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
final := decoded.final final := decoded.final
var resolutionSnapshots []resolutionSnapshot var resolutionSnapshots []resolutionSnapshot
if definition.Resolve != nil { if definition.Resolve != nil {
entry := newResolutionInput(metadata, decoded.final, input.sources, input.contexts) sources := input.sources
if input.versionState != nil {
sources = sourceInfosWithVersionEvidence(sources, input.versionState.evidence, nil, true)
}
entry := newResolutionInput(metadata, decoded.final, sources, input.contexts)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry) resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
diagnostics = append(diagnostics, resolved.diagnostics...) diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) { if hasErrors(resolved.diagnostics) {
@@ -73,7 +78,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} else if input.document != nil { } else if input.document != nil {
applications = []historyApplication{{layer: LayerBase, value: input.document}} applications = []historyApplication{{layer: LayerBase, value: input.document}}
} }
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, inheritance: input.inheritance, contexts: input.contexts}, diagnostics var versionEvidence []*sourceVersionEvidence
if input.versionState != nil {
versionEvidence = append(versionEvidence, input.versionState.evidence...)
}
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
} }
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) { func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
@@ -97,7 +106,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
decoded, metadata := resolved.decoded, resolved.metadata decoded, metadata := resolved.decoded, resolved.metadata
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, decoded.final)
sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false)
retainedTrees := make([]*document.Node, 0, len(applications)+2) retainedTrees := make([]*document.Node, 0, len(applications)+2)
retainedTrees = append(retainedTrees, decoded.final, final) retainedTrees = append(retainedTrees, decoded.final, final)
for _, application := range applications { for _, application := range applications {
@@ -105,7 +115,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
} }
inspection := &Inspection{ inspection := &Inspection{
metadata: metadata, metadata: metadata,
sources: cloneSourceInfos(accumulator.sources), sources: sources,
contexts: cloneResourceContexts(resolved.contexts), contexts: cloneResourceContexts(resolved.contexts),
diagnostics: canonicalDiagnostics(accumulator.diagnostics), diagnostics: canonicalDiagnostics(accumulator.diagnostics),
baseline: decoded.baseline, baseline: decoded.baseline,

View File

@@ -463,6 +463,41 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
} }
} }
func TestVersionWarningDoesNotSuppressReturnedError(t *testing.T) {
t.Parallel()
type config struct{}
versionPath := Keys("schema_version")
result, err := testLoad(Bytes("version.yml", []byte("{}\n")), Options[config]{
Definition: Definition[config]{Version: &VersionRule{
Path: versionPath,
Check: func(_ Version, validation *Validation) error {
validation.Warning(versionPath, "review schema compatibility")
return errors.New("CANARY_VERSION_ERROR")
},
}},
})
if result != nil || err == nil {
t.Fatalf("result = %#v, err = %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
var warning, hookFailure bool
for _, diagnostic := range diagnostics {
if strings.Contains(diagnostic.Message, "CANARY") {
t.Fatalf("returned checker error text was exposed: %#v", diagnostic)
}
warning = warning || diagnostic.Code == CodeVersionWarning && diagnostic.Severity == SeverityWarning
hookFailure = hookFailure || diagnostic.Code == CodeVersionHookFailed && diagnostic.Severity == SeverityError
}
if !warning || !hookFailure {
t.Fatalf("diagnostics = %#v", diagnostics)
}
}
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) { func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -149,12 +149,9 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
walkDocument(tree, Path{}, add) walkDocument(tree, Path{}, add)
} }
for _, application := range applications { for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer. walkHistoryApplication(application, func(_ Path, effective Path, _ *document.Node) {
prefix := Path{} add(effective, nil)
for _, segment := range application.path.segments { })
prefix = prefix.Key(segment.key)
}
walkDocument(application.value, prefix, add)
} }
for _, operation := range m.inheritance { for _, operation := range m.inheritance {
if operation.control != nil { if operation.control != nil {

View File

@@ -42,6 +42,34 @@ items: {main: main}
} }
} }
func TestNativeFilesystemItemImportUsesSharedScopedAssembly(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
}
type config struct {
Items []item `yaml:"items"`
}
filesystem := fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: records/item.yml\n destination: [items]\n placement: sequence-item\n")},
"records/item.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: helper.yml\nid: native\n")},
"records/helper.yml": &fstest.MapFile{Data: []byte("name: helper\n")},
}
namespace := newNativeNamespace("bundle", filesystem)
result, err := testLoad[config](filesystemFile(namespace, "main.yml"), Options[config]{Imports: true})
if err != nil {
t.Fatalf("native item import: %v", err)
}
if len(result.Config.Items) != 1 || result.Config.Items[0].ID != "native" || result.Config.Items[0].Name != "helper" {
t.Fatalf("native item = %#v", result.Config.Items)
}
sources := result.Sources()
if len(sources) != 3 || sources[1].Destination.String() != "/items" || sources[2].Destination.String() != "/items/0" || sources[1].Path != "records/item.yml" || sources[2].Path != "records/helper.yml" {
t.Fatalf("native item sources = %#v", sources)
}
}
func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) { func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) {
first := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: first}\n")}} first := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: first}\n")}}
second := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: second}\n")}} second := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: second}\n")}}

View File

@@ -9,6 +9,17 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
options = supplied[0] options = supplied[0]
} }
var diagnostics []Diagnostic var diagnostics []Diagnostic
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
}
if normalized, err := normalizeDestination(options.destination); err != nil {
diagnostics = append(diagnostics, invalidDestinationDiagnostic(options.destination))
} else {
options.destination = normalized
}
if options.placementMode == placementModeSequenceItem && len(options.destination.segments) == 0 {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination"))
}
definition := options.Definition definition := options.Definition
if !validOverlayName(options.Overlay) { if !validOverlayName(options.Overlay) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
@@ -33,6 +44,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
} }
} }
if options.SourceVersionMode != sourceVersionModeDefault && options.SourceVersionMode != sourceVersionModeIfPresent && options.SourceVersionMode != sourceVersionModeRequired {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid"))
} else if options.SourceVersionMode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker"))
}
return options, canonicalDiagnostics(diagnostics) return options, canonicalDiagnostics(diagnostics)
} }

View File

@@ -186,11 +186,42 @@ type SourceInfo struct {
ID string ID string
Label string Label string
Path string Path string
Destination Path
Layer Layer Layer Layer
Kind SourceKind Kind SourceKind
UnderlayIndex int UnderlayIndex int
ImportedFrom *Location ImportedFrom *Location
VersionCheck *SourceVersionCheck
namespace *namespaceToken namespace *namespaceToken
placementMode placementMode
destinationUnresolved bool
}
// SourceInfoPlacementModeForFacade converts private placement evidence to its
// root-owned numeric representation.
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
return uint8(source.placementMode)
}
// SourceInfoDestinationUnresolvedForFacade returns the private fallback marker.
func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool {
return source.destinationUnresolved
}
// SourceInfoWithPlacement copies facade-owned source evidence into private fields.
func SourceInfoWithPlacement(source SourceInfo, mode uint8, unresolved bool) SourceInfo {
source.placementMode = placementMode(mode)
source.destinationUnresolved = unresolved
return source
}
// SourceVersionCheck is a detached, public-safe record of a source-local
// version declaration observed during an enabled load.
type SourceVersionCheck struct {
Path Path
Version Version
Location *Location
Redacted bool
} }
// Diagnostic is a safe structured problem report. Message and source labels must // Diagnostic is a safe structured problem report. Message and source labels must
@@ -260,9 +291,21 @@ func cloneLocations(locations []Location) []Location {
func cloneSourceInfo(source SourceInfo) SourceInfo { func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom) source.ImportedFrom = cloneLocation(source.ImportedFrom)
source.Destination = source.Destination.clone()
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
return source return source
} }
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
if value == nil {
return nil
}
cloned := *value
cloned.Path = value.Path.clone()
cloned.Location = cloneLocation(value.Location)
return &cloned
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo { func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil { if sources == nil {
return nil return nil

View File

@@ -142,6 +142,7 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
name string name string
main string main string
child string child string
helper string
failRead string failRead string
options Options[config] options Options[config]
wantErr bool wantErr bool
@@ -150,6 +151,16 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
{name: "acquisition", main: "value: retained\n", failRead: "main.yml", wantErr: true}, {name: "acquisition", main: "value: retained\n", failRead: "main.yml", wantErr: true},
{name: "parse", main: "value: [broken\n", wantErr: true}, {name: "parse", main: "value: [broken\n", wantErr: true},
{name: "composition", main: "configkit: {imports: [{path: child.yml}]}\nvalue: first\n", child: "value: second\n", options: Options[config]{Imports: true}, wantErr: true}, {name: "composition", main: "configkit: {imports: [{path: child.yml}]}\nvalue: first\n", child: "value: second\n", options: Options[config]{Imports: true}, wantErr: true},
{
name: "nested item composition",
main: "configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n",
child: "configkit: {imports: [{path: helper.yml}]}\nid: child\nvalue: child\n",
helper: "value: helper\n",
options: Options[config]{
Imports: true, destination: Keys("groups"), placementMode: placementModeSequenceItem,
},
wantErr: true,
},
{name: "version", main: "version: wrong\n", options: Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return fs.ErrInvalid }}}}, wantErr: true}, {name: "version", main: "version: wrong\n", options: Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return fs.ErrInvalid }}}}, wantErr: true},
{name: "validation", main: "value: retained\n", options: Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) { validation.Error(Keys("value"), "rejected") }}}, wantErr: true}, {name: "validation", main: "value: retained\n", options: Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) { validation.Error(Keys("value"), "rejected") }}}, wantErr: true},
} { } {
@@ -158,6 +169,9 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
if test.child != "" { if test.child != "" {
files["child.yml"] = &fstest.MapFile{Data: []byte(test.child)} files["child.yml"] = &fstest.MapFile{Data: []byte(test.child)}
} }
if test.helper != "" {
files["helper.yml"] = &fstest.MapFile{Data: []byte(test.helper)}
}
filesystem := &observedFilesystem{files: files, failRead: test.failRead} filesystem := &observedFilesystem{files: files, failRead: test.failRead}
_, err := testLoad(filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), test.options) _, err := testLoad(filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), test.options)
if (err != nil) != test.wantErr { if (err != nil) != test.wantErr {

View File

@@ -1,6 +1,10 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import (
"slices"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type secretDependency struct { type secretDependency struct {
target Path target Path
@@ -8,19 +12,45 @@ type secretDependency struct {
} }
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool { func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
return classifySecretPathsWithEvidence(schema, predicate, metadata, final, applications, nil, projections...)
}
func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, evidence []*sourceVersionEvidence, projections ...*document.Node) map[string]bool {
paths := make(map[string]Path) paths := make(map[string]Path)
candidateShapes := make(map[string][]Path)
var candidates []Path
addCandidate := func(path Path) {
pointer := path.String()
for _, prior := range candidateShapes[pointer] {
if slices.Equal(path.segments, prior.segments) {
return
}
}
cloned := path.clone()
candidateShapes[pointer] = append(candidateShapes[pointer], cloned)
candidates = append(candidates, cloned)
}
for _, application := range applications { for _, application := range applications {
walkDocument(application.value, application.path, func(path Path, _ *document.Node) { walkHistoryApplication(application, func(_ Path, path Path, _ *document.Node) {
if len(application.sequences) != 0 {
addCandidate(path)
} else if parsed, err := ParsePath(path.String()); err == nil {
addCandidate(parsed)
}
addSecretPath(paths, path.String()) addSecretPath(paths, path.String())
}) })
} }
var dependencies []secretDependency var dependencies []secretDependency
for pointer, history := range metadata.history { for pointer, history := range metadata.history {
addSecretPath(paths, pointer) addSecretPath(paths, pointer)
if path, err := ParsePath(pointer); err == nil {
addCandidate(path)
}
for _, change := range history { for _, change := range history {
paths[change.Path.String()] = change.Path.clone() paths[change.Path.String()] = change.Path.clone()
for _, dependency := range change.Dependencies { for _, dependency := range change.Dependencies {
paths[dependency.String()] = dependency.clone() paths[dependency.String()] = dependency.clone()
addCandidate(dependency)
dependencies = append(dependencies, secretDependency{ dependencies = append(dependencies, secretDependency{
target: change.Path.clone(), dependency: dependency.clone(), target: change.Path.clone(), dependency: dependency.clone(),
}) })
@@ -32,8 +62,10 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
if operation.control != nil { if operation.control != nil {
control := operation.control.path control := operation.control.path
paths[control.String()] = control.clone() paths[control.String()] = control.clone()
addCandidate(control)
for _, contributor := range operation.controlOrigin.contributors { for _, contributor := range operation.controlOrigin.contributors {
paths[contributor.source.String()] = contributor.source.clone() paths[contributor.source.String()] = contributor.source.clone()
addCandidate(contributor.source)
inherited = append(inherited, secretDependency{target: control.clone(), dependency: contributor.source.clone()}) inherited = append(inherited, secretDependency{target: control.clone(), dependency: contributor.source.clone()})
} }
continue continue
@@ -53,6 +85,8 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
destination := appendInheritancePath(operation.transfer.to, relative) destination := appendInheritancePath(operation.transfer.to, relative)
paths[pointer] = source paths[pointer] = source
paths[destination.String()] = destination.clone() paths[destination.String()] = destination.clone()
addCandidate(source)
addCandidate(destination)
inherited = append(inherited, secretDependency{target: destination, dependency: source}) inherited = append(inherited, secretDependency{target: destination, dependency: source})
} }
} }
@@ -62,6 +96,9 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
walkDocument(tree, Path{}, func(path Path, node *document.Node) { walkDocument(tree, Path{}, func(path Path, node *document.Node) {
pointer := path.String() pointer := path.String()
addSecretPath(paths, pointer) addSecretPath(paths, pointer)
if parsed, err := ParsePath(pointer); err == nil {
addCandidate(parsed)
}
if node.Sensitive { if node.Sensitive {
secret[pointer] = true secret[pointer] = true
} }
@@ -71,17 +108,38 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
classifyProjection(tree) classifyProjection(tree)
} }
classifyProjection(final) classifyProjection(final)
for pointer, path := range paths { classifyPath := func(path Path) {
if root, ok := schemaSecretRoot(schema, path); ok { if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true secret[root.String()] = true
} }
if predicate != nil && predicate(path.clone()) { if predicate != nil && predicate(path.clone()) {
secret[pointer] = true secret[path.String()] = true
}
}
for _, path := range candidates {
classifyPath(path)
}
// Classify each original evidence shape before folding results into pointer
// sensitivity. Numeric mapping keys and sequence indices can share a pointer.
for _, item := range evidence {
if item == nil {
continue
}
for _, resolved := range item.sensitivityPaths() {
addSecretPath(paths, resolved.String())
addCandidate(resolved)
classifyPath(resolved)
} }
} }
return closeSecretDependencies(paths, secret, dependencies, inherited) return closeSecretDependencies(paths, secret, dependencies, inherited)
} }
func addSecretEvidencePath(paths map[string]Path, destination, local Path) Path {
path := appendDestination(destination, local)
addSecretPath(paths, path.String())
return path
}
func addSecretPath(paths map[string]Path, pointer string) { func addSecretPath(paths map[string]Path, pointer string) {
if existing, found := paths[pointer]; found && existing.valid() { if existing, found := paths[pointer]; found && existing.valid() {
// Accumulator keys already match their paths' canonical spelling. History // Accumulator keys already match their paths' canonical spelling. History

View File

@@ -1,6 +1,9 @@
package engine package engine
import "testing" import (
"reflect"
"testing"
)
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) { func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} { for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} {
@@ -77,3 +80,123 @@ func TestSecretPredicateReceivesDetachedHistoricalPath(t *testing.T) {
t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen) t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen)
} }
} }
func TestSourceVersionEvidenceSensitivityUsesOriginalMountedPaths(t *testing.T) {
const canary = "PRIVATE-VERSION-CANARY"
type credential struct {
Value string `yaml:"credential" configkit:"secret"`
Public string `yaml:"public"`
}
type account struct {
Token string `yaml:"token"`
}
type tenant struct {
Groups map[string][]credential `yaml:"groups"`
Account account `yaml:"account" configkit:"secret"`
Settings map[string]string `yaml:"settings"`
Labels map[string]string `yaml:"labels"`
}
type configuration struct {
Tenant tenant `yaml:"tenant"`
}
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
if issue != nil {
t.Fatal(issue)
}
destination := Keys("tenant")
sequencePath := Keys("groups").Key("blue").Index(2).Key("credential")
accountPath := Keys("account", "token")
predicatePath := Keys("settings", "private")
nullPath := Keys("settings", "nullable")
absentPath := Keys("settings", "missing")
publicPath := Keys("labels", "public")
evidence := []*sourceVersionEvidence{
{
path: sequencePath.clone(), resolvedPath: sequencePath.clone(), destination: destination.clone(),
version: Version{Present: true, Text: canary},
ancestors: []sourceVersionAncestor{{}, {path: Keys("groups")}, {path: Keys("groups").Key("blue")}, {path: Keys("groups").Key("blue").Index(2)}, {path: sequencePath.clone()}},
},
{
path: accountPath.clone(), resolvedPath: accountPath.clone(), destination: destination.clone(),
version: Version{Present: true, Text: canary},
ancestors: []sourceVersionAncestor{{}, {path: Keys("account")}, {path: accountPath.clone()}},
},
{path: predicatePath.clone(), resolvedPath: predicatePath.clone(), destination: destination.clone(), version: Version{Present: true, Text: canary}},
{path: nullPath.clone(), resolvedPath: nullPath.clone(), destination: destination.clone(), version: Version{Present: true, Null: true}},
{path: absentPath.clone(), resolvedPath: absentPath.clone(), destination: destination.clone()},
{path: publicPath.clone(), resolvedPath: publicPath.clone(), destination: destination.clone(), version: Version{Present: true, Text: canary}},
}
final := parseCompositionDocument(t, "final", "tenant:\n groups:\n blue:\n - public: shifted-neighbor\n labels:\n public: visible\n")
var sawOriginalSequencePath, sawAncestorPredicate bool
classified := classifySecretPathsWithEvidence(schema, func(path Path) bool {
if path.String() == appendDestination(destination, predicatePath).String() {
return true
}
if path.String() == appendDestination(destination, nullPath).String() {
return true
}
if path.String() == appendDestination(destination, absentPath).String() {
return true
}
if path.String() == appendDestination(destination, Keys("account")).String() {
sawAncestorPredicate = true
return true
}
if path.String() == appendDestination(destination, sequencePath).String() {
sawOriginalSequencePath = len(path.segments) == 5 && path.segments[3].kind == pathSegmentIndex && path.segments[3].index == 2
}
return false
}, Metadata{}, final, []historyApplication{{path: destination, value: final}}, evidence)
for _, path := range []Path{
appendDestination(destination, sequencePath),
appendDestination(destination, Keys("account")),
appendDestination(destination, predicatePath),
appendDestination(destination, nullPath),
appendDestination(destination, absentPath),
} {
if !classified[path.String()] {
t.Errorf("evidence path %s was not classified: %#v", path, classified)
}
}
shiftedNeighbor := appendDestination(destination, Keys("groups").Key("blue").Index(0).Key("public"))
if classified[shiftedNeighbor.String()] {
t.Fatalf("shifted neighboring payload was classified from declaration evidence: %#v", classified)
}
if classified[appendDestination(destination, publicPath).String()] || !sawOriginalSequencePath || !sawAncestorPredicate {
t.Fatalf("unclassified or semantic evidence candidates = %#v; original path seen %v, ancestor predicate seen %v", classified, sawOriginalSequencePath, sawAncestorPredicate)
}
}
func TestSourceVersionEvidenceClassificationKeepsCollidingShapes(t *testing.T) {
t.Parallel()
type header struct {
Version string `yaml:"version" configkit:"secret"`
}
type configuration struct {
Meta map[string]header `yaml:"meta"`
}
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
if issue != nil {
t.Fatal(issue)
}
keyPath := Keys("meta", "0", "version")
indexPath := Keys("meta").Index(0).Key("version")
for _, reverse := range []bool{false, true} {
evidence := []*sourceVersionEvidence{{resolvedPath: keyPath}, {resolvedPath: indexPath}}
if reverse {
evidence[0], evidence[1] = evidence[1], evidence[0]
}
var sawKey, sawIndex bool
classified := classifySecretPathsWithEvidence(schema, func(path Path) bool {
sawKey = sawKey || reflect.DeepEqual(path, keyPath)
sawIndex = sawIndex || reflect.DeepEqual(path, indexPath)
return false
}, Metadata{}, nil, nil, evidence)
if !classified[keyPath.String()] || !sawKey || !sawIndex {
t.Errorf("reverse=%v: classified=%v, key seen=%v, index seen=%v", reverse, classified, sawKey, sawIndex)
}
}
}

View File

@@ -38,6 +38,10 @@ func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main
} }
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) { func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil)
}
func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) {
if nativeContext(source, context) { if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context) access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil { if diagnostic != nil {
@@ -46,7 +50,7 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil { if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
return cloneSourceInfos(sources), []Diagnostic{*diagnostic} return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
} }
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits) return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
} }
rootPath, _, _, diagnostic := importFilesystem(source, context, main) rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil { if diagnostic != nil {
@@ -58,10 +62,14 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
value.Primary = nil value.Primary = nil
return cloneSourceInfos(sources), []Diagnostic{value} return cloneSourceInfos(sources), []Diagnostic{value}
} }
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits) return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
} }
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) { func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil)
}
func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) {
closed := false closed := false
defer func() { defer func() {
if !closed { if !closed {
@@ -126,7 +134,15 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 { if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
return sources, diagnostics return sources, diagnostics
} }
if versionNode := fragmentVersionClaim(overlayRoot, rule); versionNode != nil { overlayPayload := overlayRoot
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
if len(failures) != 0 {
return sources, failures
}
overlayPayload = processed.payload
}
if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil {
location := publicLocation(versionNode.Location) location := publicLocation(versionNode.Location)
return sources, []Diagnostic{{ return sources, []Diagnostic{{
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(), Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
@@ -139,7 +155,7 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)} return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
} }
if diagnostics := base.applyOverlay(overlayRoot, nil, nil, LayerOverlay); len(diagnostics) != 0 { if diagnostics := base.applyOverlay(overlayPayload, nil, nil, LayerOverlay); len(diagnostics) != 0 {
return sources, diagnostics return sources, diagnostics
} }
base.current.inputBytes += int64(len(data)) base.current.inputBytes += int64(len(data))

View File

@@ -0,0 +1,54 @@
package engine
type sequenceRegistration struct {
path Path
identityField string
}
type sequencePolicy struct {
identityFields map[string]string
}
// DefinitionWithSequenceRegistrations attaches facade-owned registrations to a
// detached engine definition without exposing the registration representation.
func DefinitionWithSequenceRegistrations[T any](definition Definition[T], paths []Path, identityFields []string) Definition[T] {
if paths == nil {
definition.sequenceRegistrations = nil
return definition
}
definition.sequenceRegistrations = make([]sequenceRegistration, len(paths))
for index := range paths {
definition.sequenceRegistrations[index] = sequenceRegistration{
path: paths[index].clone(), identityField: identityFields[index],
}
}
return definition
}
func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) {
policy := sequencePolicy{identityFields: make(map[string]string, len(registrations))}
var diagnostics []Diagnostic
for _, registration := range registrations {
path, err := normalizeDestination(registration.path)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: registration.path.clone(), Message: "sequence registration path must contain only non-reserved mapping keys",
})
continue
}
key := path.String()
if current, ok := policy.identityFields[key]; ok {
if current != registration.identityField {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: path, Message: "sequence registrations at one path must use the same identity field",
})
}
continue
}
policy.identityFields[key] = registration.identityField
}
return policy, canonicalDiagnostics(diagnostics)
}

View File

@@ -0,0 +1,306 @@
package engine
import (
"errors"
"strings"
"testing"
"testing/fstest"
)
func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
parsed, err := ParsePath("/items/0/a~1b")
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
registrations []sequenceRegistration
want map[string]string
wantError bool
}{
{
name: "identical registrations coalesce",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "id"},
},
want: map[string]string{"/items": "id"},
},
{
name: "different identity fields conflict",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "name"},
},
wantError: true,
},
{
name: "empty identity field is literal",
registrations: []sequenceRegistration{{path: Keys("items"), identityField: ""}},
want: map[string]string{"/items": ""},
},
{
name: "numeric and escaped tokens remain literal keys",
registrations: []sequenceRegistration{{path: parsed, identityField: "id"}},
want: map[string]string{"/items/0/a~1b": "id"},
},
{
name: "explicit index is invalid",
registrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}},
wantError: true,
},
{
name: "unused and root registrations are accepted",
registrations: []sequenceRegistration{
{path: Path{}, identityField: "id"},
{path: Keys("unused"), identityField: "name"},
},
want: map[string]string{"": "id", "/unused": "name"},
},
{name: "zero option policy", want: map[string]string{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
policy, diagnostics := compileSequencePolicy(test.registrations)
if (len(diagnostics) != 0) != test.wantError {
t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError)
}
if test.wantError {
if diagnostics[0].Code != CodeOptionsInvalid {
t.Fatalf("diagnostic code = %q", diagnostics[0].Code)
}
return
}
if len(policy.identityFields) != len(test.want) {
t.Fatalf("compiled fields = %#v, want %#v", policy.identityFields, test.want)
}
for path, field := range test.want {
if got := policy.identityFields[path]; got != field {
t.Errorf("identity field at %q = %q, want %q", path, got, field)
}
}
})
}
}
func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
ordinaryReader := strings.NewReader("value: ordinary\n")
_, err := Load[config](Reader("ordinary.yml", ordinaryReader), SecretBinding{}, Options[config]{
Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}},
})
requireSequenceRegistrationOptionError(t, err)
if ordinaryReader.Len() == 0 {
t.Fatal("ordinary reader was consumed before invalid registration was rejected")
}
mainReader := strings.NewReader("value: main\n")
underlayReader := strings.NewReader("value: underlay\n")
_, err = LoadLayered[config](Input{Source: Reader("main.yml", mainReader)}, SecretBinding{}, LayeredOptions[config]{
Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}},
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayReader)}}}},
})
requireSequenceRegistrationOptionError(t, err)
if mainReader.Len() == 0 || underlayReader.Len() == 0 {
t.Fatal("layered source was consumed before invalid registration was rejected")
}
}
func requireSequenceRegistrationOptionError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected invalid sequence registration error")
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error type = %T, want *LoadError", err)
}
diagnostics := loadErr.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOptionsInvalid {
t.Fatalf("diagnostics = %#v, want one options diagnostic", diagnostics)
}
}
func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
t.Parallel()
type item struct {
ID string `yaml:"id"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
registered := Options[config]{Definition: definition}
loadCode := func(t *testing.T, err error) Code {
t.Helper()
var loadErr *LoadError
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
t.Fatalf("error = %v, want a diagnostic load error", err)
}
return loadErr.Diagnostics()[0].Code
}
t.Run("ordinary authored list is checked with one source", func(t *testing.T) {
if _, err := testLoad[config](Bytes("main", []byte("items: [{id: same}, {id: same}]\n")), registered); err == nil || loadCode(t, err) != CodeComposeConflict {
t.Fatalf("duplicate identity error = %v", err)
}
result, err := testLoad[config](Bytes("main", []byte("items: [{id: one}]\n")), registered)
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "one" {
t.Fatalf("single-source load = %#v, %v", result, err)
}
})
t.Run("ordinary imports retain registered composition", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\nitems: [{id: authored}]\n")
writeImportFile(t, directory, "fragment.yml", "items: [{id: imported}]\n")
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[1].ID != "imported" {
t.Fatalf("registered imported list = %#v, %v", result, err)
}
})
t.Run("strict underlay group appends while separate tiers reset identities", func(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: [{id: shared}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
{source: Bytes("second", []byte("items: [{id: other}]\n"))},
}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
}
result, err := testGroupedLoad(request)
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "shared" {
t.Fatalf("grouped load = %#v, %v", result, err)
}
request.underlays = []loadGroup{{inputs: []loadInput{
{source: Bytes("first", []byte("items: [{id: shared}]\n"))},
{source: Bytes("second", []byte("items: [{id: lower}]\n"))},
}}}
request.main.source = Bytes("main", []byte("items: [{id: shared}]\n"))
if _, err := testGroupedLoad(request); err != nil {
t.Fatalf("same identity in a separate tier: %v", err)
}
})
t.Run("empty registered contribution clears lower sequence", func(t *testing.T) {
result, err := testLoad[config](Bytes("main", []byte("items: []\n")), registered)
if err != nil || len(result.Config.Items) != 0 {
t.Fatalf("empty sequence load = %#v, %v", result, err)
}
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: []\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
}
result, err = testGroupedLoad(request)
if err != nil || len(result.Config.Items) != 0 {
t.Fatalf("empty higher tier = %#v, %v", result, err)
}
})
t.Run("unregistered list overlap remains atomic", func(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
{source: Bytes("second", []byte("items: [{id: other}]\n"))},
}}},
}
_, err := testGroupedLoad(request)
if got := loadCode(t, err); got != CodeComposeConflict {
t.Fatalf("unregistered overlap error = %s", got)
}
})
t.Run("malformed lower tier fails before precedence replacement", func(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: same}, {id: same}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
}
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
t.Fatalf("hidden lower-tier error = %s", got)
}
overlayFilesystem := NewFilesystem("overlay", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("items: [{id: overlay}]\n")},
})
request.overlay = "testing"
request.overlayFilesystem = &FilesystemContext{Root: ".", BaseDir: ".", FS: &overlayFilesystem}
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
t.Fatalf("selected replacement overlay hid lower-tier error: %s", got)
}
})
t.Run("replacement overlay and CLI may introduce duplicates", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: [{id: base}]\n")
writeImportFile(t, directory, "overlays/testing.yml", "items: [{id: duplicate}, {id: duplicate}]\n")
result, err := testLoad[config](File(mainPath), Options[config]{Definition: definition, Overlay: "testing"})
if err != nil {
t.Fatalf("replacement overlay error code = %s", loadCode(t, err))
}
if len(result.Config.Items) != 2 {
t.Fatalf("replacement overlay = %#v, %v", result, err)
}
result, err = testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{
Definition: definition,
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
})
if err != nil || len(result.Config.Items) != 2 {
t.Fatalf("replacement CLI value = %#v, %v", result, err)
}
resolved := definition
resolved.Resolve = func(value *config, resolution *Resolution) error {
value.Items[1].ID = value.Items[0].ID
resolution.Record(Keys("items"), ChangeNormalization, "registered identities collide")
return nil
}
result, err = testLoad[config](Bytes("main", []byte("items: [{id: first}, {id: second}]\n")), Options[config]{Definition: resolved})
if err != nil {
t.Fatalf("resolver replacement error code = %s", loadCode(t, err))
}
if len(result.Config.Items) != 2 {
t.Fatalf("resolver replacement = %#v, %v", result, err)
}
})
t.Run("application validation can reject replacement duplicates", func(t *testing.T) {
checked := definition
checked.Validate = func(value *config, validation *Validation) {
seen := make(map[string]bool)
for index, entry := range value.Items {
if seen[entry.ID] {
validation.Error(Keys("items").Index(index).Key("id"), "identity must be unique")
}
seen[entry.ID] = true
}
}
_, err := testLoad[config](Bytes("main", []byte("items: [{id: base}]\n")), Options[config]{
Definition: checked,
Overrides: []Override{{Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "duplicate"}, map[string]any{"id": "duplicate"}}}},
})
if got := loadCode(t, err); got != CodeValidateError {
t.Fatalf("application validation error = %s", got)
}
})
}
func mustCompileSequencePolicy(t *testing.T, registrations []sequenceRegistration) sequencePolicy {
t.Helper()
policy, diagnostics := compileSequencePolicy(registrations)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
return policy
}

View File

@@ -0,0 +1,464 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
type sourceVersionMode uint8
const (
sourceVersionModeDefault sourceVersionMode = iota
sourceVersionModeIfPresent
sourceVersionModeRequired
)
// SourceVersionModeFromFacade converts the root-owned public mode while
// keeping the engine's policy type private. Unknown values remain intact for
// pre-acquisition validation.
func SourceVersionModeFromFacade(value uint8) sourceVersionMode {
return sourceVersionMode(value)
}
type sourceVersionRole uint8
const (
sourceVersionMain sourceVersionRole = iota
sourceVersionContribution
)
type sourceVersionAncestor struct {
path Path
kind document.Kind
}
type sourceVersionEvidence struct {
sourceID string
path Path
resolvedPath Path
destination Path
projectedPaths []Path
version Version
location *Location
ancestors []sourceVersionAncestor
absence sourceVersionPathAbsence
checked bool
redacted bool
}
type sourceVersionState struct {
localRule *VersionRule
effectiveRule *VersionRule
mode sourceVersionMode
mainProcessing *sourceVersionProcessingResult
evidence []*sourceVersionEvidence
diagnostics []Diagnostic
}
func sourceInfosWithVersionEvidence(sources []SourceInfo, evidence []*sourceVersionEvidence, secrets map[string]bool, conservative bool) []SourceInfo {
result := cloneSourceInfos(sources)
if len(evidence) == 0 {
return result
}
sensitivity := newDiffSensitivity(secrets, nil)
for _, item := range evidence {
if item == nil || item.sourceID == "" {
continue
}
for index := range result {
if result[index].ID == item.sourceID {
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
break
}
}
}
return result
}
func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity *diffSensitivity, conservative bool) *SourceVersionCheck {
path := evidence.resolvedPath
if !path.valid() {
path = evidence.path
}
version := evidence.version
redacted := evidence.redacted
if conservative {
redacted = redacted || version.Text != ""
} else if sensitivity.isSensitiveAtOrAbove(evidence.sensitivityPaths()[0].String()) {
redacted = true
}
if redacted {
version.Text = ""
}
return &SourceVersionCheck{
Path: path.clone(), Version: version, Location: cloneLocation(evidence.location), Redacted: redacted,
}
}
// sensitivityPaths preserves source-local evidence while describing its assigned
// application coordinates. Before assembly, the original mount is the fallback.
func (evidence *sourceVersionEvidence) sensitivityPaths() []Path {
if evidence.projectedPaths != nil {
return evidence.projectedPaths
}
local := evidence.resolvedPath
if !local.valid() {
local = evidence.path
}
paths := make([]Path, 0, len(evidence.ancestors)+1)
paths = append(paths, appendDestination(evidence.destination, local))
for _, ancestor := range evidence.ancestors {
paths = append(paths, appendDestination(evidence.destination, ancestor.path))
}
return paths
}
func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement placementMode) (sourceVersionProcessingResult, []Diagnostic) {
if state == nil || state.mode == sourceVersionModeDefault {
return sourceVersionProcessingResult{}, nil
}
projectedDestination := destination.clone()
if placement == placementModeSequenceItem {
projectedDestination = projectedDestination.Index(0)
}
result := processSourceVersion(root, state.localRule, role, projectedDestination, state.mode)
if result.evidence != nil {
result.evidence.sourceID = sourceID
state.evidence = append(state.evidence, result.evidence)
}
var failures []Diagnostic
for _, diagnostic := range result.diagnostics {
if diagnostic.Severity == SeverityWarning {
state.diagnostics = append(state.diagnostics, diagnostic)
} else if diagnostic.Severity == SeverityError {
failures = append(failures, diagnostic)
}
}
return result, failures
}
type sourceVersionProcessingResult struct {
payload *document.Node
imports []importDeclaration
evidence *sourceVersionEvidence
diagnostics []Diagnostic
}
func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule {
return effectiveSourceVersionRuleForPlacement(local, destination, mode, placementModeMapping)
}
func effectiveSourceVersionRuleForPlacement(local *VersionRule, destination Path, mode sourceVersionMode, placement placementMode) *VersionRule {
if local == nil {
return nil
}
if mode == sourceVersionModeDefault {
return local
}
effective := *local
prefix := destination.clone()
if placement == placementModeSequenceItem {
prefix = prefix.Index(0)
}
effective.Path = Path{
segments: append(clonePathSegments(prefix.segments, len(local.Path.segments)), local.Path.segments...),
invalid: prefix.invalid || local.Path.invalid,
}
return &effective
}
type sourceVersionPathAbsence uint8
const (
sourceVersionPathPresent sourceVersionPathAbsence = iota
sourceVersionPathInvalid
sourceVersionMemberMissing
sourceVersionIndexUnavailable
sourceVersionPathObstructed
)
type sourceVersionPath struct {
node *document.Node
present bool
absence sourceVersionPathAbsence
nearest document.Location
resolvedPath Path
ancestors []sourceVersionAncestor
}
func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVersionRole, destination Path, mode sourceVersionMode) sourceVersionProcessingResult {
if role != sourceVersionMain && role != sourceVersionContribution {
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source role is invalid")}}
}
if mode != sourceVersionModeDefault && mode != sourceVersionModeIfPresent && mode != sourceVersionModeRequired {
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source-version mode is invalid")}}
}
if mode != sourceVersionModeDefault && (rule == nil || rule.Check == nil || !rule.Path.valid()) {
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source-version checking requires a valid version rule and checker")}}
}
if root == nil {
if role == sourceVersionMain && mode != sourceVersionModeDefault {
return sourceVersionProcessingResult{diagnostics: canonicalDiagnostics(reportVersion(rule, Version{}, nil))}
}
return sourceVersionProcessingResult{}
}
payload, imports, diagnostics := splitImportDirectives(root)
if len(diagnostics) != 0 {
return sourceVersionProcessingResult{diagnostics: diagnostics}
}
result := sourceVersionProcessingResult{payload: payload, imports: imports}
if mode == sourceVersionModeDefault {
return result
}
resolved := resolveSourceVersionPath(root, rule.Path)
evidence := &sourceVersionEvidence{
path: rule.Path.clone(), resolvedPath: resolved.resolvedPath,
destination: destination.clone(), ancestors: resolved.ancestors,
absence: resolved.absence,
}
location := resolved.nearest
if resolved.present {
location = resolved.node.Location
}
if locationKnown(location) {
public := publicLocation(location)
evidence.location = &public
}
result.evidence = evidence
interpreted := interpretVersion(rule, root)
evidence.version = interpreted.version
if resolved.present && sourceVersionControlOverlap(root, evidence.resolvedPath) {
evidence.redactText()
result.payload = nil
result.diagnostics = []Diagnostic{sourceVersionOverlapDiagnostic(rule, evidence)}
return result
}
if hasErrors(interpreted.diagnostics) {
evidence.redactText()
result.payload = nil
result.diagnostics = canonicalDiagnostics(interpreted.diagnostics)
return result
}
if !interpreted.version.Present {
if mode == sourceVersionModeRequired {
evidence.redactText()
result.payload = nil
result.diagnostics = []Diagnostic{sourceVersionMissingDiagnostic(rule, evidence)}
return result
}
if role == sourceVersionContribution {
return result
}
}
evidence.checked = true
result.diagnostics = canonicalDiagnostics(reportVersion(rule, interpreted.version, root))
if hasErrors(result.diagnostics) {
evidence.redactText()
result.payload = nil
return result
}
if role == sourceVersionContribution && interpreted.version.Present {
if !removeSourceVersionNode(result.payload, evidence.resolvedPath) {
evidence.redactText()
result.payload = nil
result.diagnostics = []Diagnostic{sourceVersionOptionsDiagnostic("selected source version could not be removed from its owned payload")}
return result
}
}
return result
}
func sourceVersionOptionsDiagnostic(message string) Diagnostic {
return Diagnostic{Code: CodeOptionsInvalid, Severity: SeverityError, Message: message}
}
func sourceVersionMissingDiagnostic(rule *VersionRule, evidence *sourceVersionEvidence) Diagnostic {
return Diagnostic{
Code: CodeVersionError, Severity: SeverityError, Path: rule.Path.clone(),
Message: "required schema version is missing", Primary: cloneLocation(evidence.location),
}
}
func sourceVersionOverlapDiagnostic(rule *VersionRule, evidence *sourceVersionEvidence) Diagnostic {
return Diagnostic{
Code: CodeVersionError, Severity: SeverityError, Path: rule.Path.clone(),
Message: "schema version path overlaps a structural directive", Primary: cloneLocation(evidence.location),
}
}
func (evidence *sourceVersionEvidence) redactText() {
if evidence == nil {
return
}
evidence.version.Text = ""
evidence.redacted = true
}
func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath {
// Refine only segments whose container shape is known, keeping the full
// requested suffix when lookup stops before the declaration.
resolved := sourceVersionPath{absence: sourceVersionPathInvalid, resolvedPath: path.clone()}
if root == nil || !path.valid() {
return resolved
}
current := root
resolved.nearest = current.Location
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind})
for position, segment := range path.segments {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
resolved.absence = sourceVersionPathObstructed
return resolved
}
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentKey, key: segment.key}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
found = true
break
}
}
if !found {
resolved.absence = sourceVersionMemberMissing
return resolved
}
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok {
resolved.absence = sourceVersionIndexUnavailable
return resolved
}
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentIndex, index: index}
if index >= len(current.Sequence) {
resolved.absence = sourceVersionIndexUnavailable
return resolved
}
current = current.Sequence[index]
default:
resolved.absence = sourceVersionPathObstructed
return resolved
}
resolved.nearest = current.Location
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{
path: Path{segments: clonePathSegments(resolved.resolvedPath.segments[:position+1], 0)}, kind: current.Kind,
})
}
resolved.node = current
resolved.present = true
resolved.absence = sourceVersionPathPresent
return resolved
}
func sourceVersionControlOverlap(root *document.Node, path Path) bool {
if root == nil || !path.valid() {
return false
}
if root.Kind == document.Mapping {
for _, entry := range root.Mapping {
if entry.Key == directiveKey && pathHasPrefix(path, Keys(directiveKey)) {
return true
}
}
}
return sourceVersionInheritanceOverlap(root, Path{}, path)
}
func sourceVersionInheritanceOverlap(node *document.Node, current, path Path) bool {
if node == nil {
return false
}
if node.Kind == document.Mapping {
if _, found := inheritanceDirective(node); found {
controlPath := current.Key(inheritanceKey)
if pathHasPrefix(path, controlPath) {
return true
}
}
for _, entry := range node.Mapping {
if entry.Key == inheritanceKey {
continue
}
if sourceVersionInheritanceOverlap(entry.Value, current.Key(entry.Key), path) {
return true
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if sourceVersionInheritanceOverlap(child, current.Index(index), path) {
return true
}
}
}
return false
}
func pathHasPrefix(path, prefix Path) bool {
if !path.valid() || !prefix.valid() || len(prefix.segments) > len(path.segments) {
return false
}
for index, segment := range prefix.segments {
candidate := path.segments[index]
if candidate.kind != segment.kind || candidate.key != segment.key || candidate.index != segment.index {
return false
}
}
return true
}
func removeSourceVersionNode(root *document.Node, path Path) bool {
if root == nil || !path.valid() || len(path.segments) == 0 {
return false
}
current := root
for _, segment := range path.segments[:len(path.segments)-1] {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return false
}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
found = true
break
}
}
if !found {
return false
}
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {
return false
}
current = current.Sequence[index]
default:
return false
}
}
last := path.segments[len(path.segments)-1]
switch current.Kind {
case document.Mapping:
if last.kind == pathSegmentIndex {
return false
}
for index, entry := range current.Mapping {
if entry.Key == last.key {
current.Mapping = append(current.Mapping[:index], current.Mapping[index+1:]...)
return true
}
}
case document.Sequence:
index, ok := schemaIndex(last)
if !ok || index >= len(current.Sequence) {
return false
}
current.Sequence = append(current.Sequence[:index], current.Sequence[index+1:]...)
return true
}
return false
}

File diff suppressed because it is too large Load Diff

View File

@@ -187,34 +187,59 @@ func validationValueMutations(before, after *document.Node) []Diagnostic {
return diagnostics return diagnostics
} }
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { type versionCheckResult struct {
if rule == nil { version Version
return nil diagnostics []Diagnostic
} }
version := Version{}
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
return checkVersionResult(rule, root).diagnostics
}
func checkVersionResult(rule *VersionRule, root *document.Node) versionCheckResult {
if rule == nil {
return versionCheckResult{}
}
result := interpretVersion(rule, root)
if hasErrors(result.diagnostics) {
result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result
}
result.diagnostics = append(result.diagnostics, reportVersion(rule, result.version, root)...)
result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result
}
func interpretVersion(rule *VersionRule, root *document.Node) versionCheckResult {
result := versionCheckResult{}
node, present := documentAtPath(root, rule.Path) node, present := documentAtPath(root, rule.Path)
if present { if present {
version.Present = true result.version.Present = true
switch node.Kind { switch node.Kind {
case document.Null: case document.Null:
version.Null = true result.version.Null = true
case document.String: case document.String:
version.Text = node.Text result.version.Text = node.Text
case document.Number: case document.Number:
if isIntegerVersion(node) { if isIntegerVersion(node) {
version.Text = node.Text result.version.Text = node.Text
break return result
} }
fallthrough fallthrough
default: default:
location, _ := nearestDocumentLocation(root, rule.Path) location, _ := nearestDocumentLocation(root, rule.Path)
primary := documentLocationPointer(location) primary := documentLocationPointer(location)
return []Diagnostic{{ result.diagnostics = []Diagnostic{{
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(), Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
Message: "schema version must be a string, integer, or null", Primary: primary, Message: "schema version must be a string, integer, or null", Primary: primary,
}} }}
return result
} }
} }
return result
}
func reportVersion(rule *VersionRule, version Version, root *document.Node) []Diagnostic {
state := &validationState{ state := &validationState{
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
invalidPath: CodeVersionError, document: root, invalidPath: CodeVersionError, document: root,
@@ -229,7 +254,7 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
Message: "schema-version check failed", Primary: documentLocationPointer(location), Message: "schema-version check failed", Primary: documentLocationPointer(location),
}) })
} }
return canonicalDiagnostics(state.diagnostics) return state.diagnostics
} }
func isIntegerVersion(node *document.Node) bool { func isIntegerVersion(node *document.Node) bool {

View File

@@ -2,11 +2,20 @@ package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// Input selects one source, whether its imports are enabled, and any explicit // Input selects one source, its application destination, whether its imports are
// filesystem context. A zero Source is invalid; use NoSource() for an omitted // enabled, and any explicit filesystem context. A zero Source is invalid; use
// main input. Sources keep their documented reuse and reader ownership rules. // NoSource() for an omitted main input. Sources keep their documented reuse and
// reader ownership rules.
type Input struct { type Input struct {
Source Source Source Source
// Destination establishes the mapping path for Source. Imports inherit this
// path or append a relative destination declared in YAML. Its zero value
// keeps root placement.
Destination Path
// PlacementMode defaults to mapping placement. Sequence-item placement wraps
// the source mapping as one record; unknown values and an empty destination
// fail option validation before acquisition.
PlacementMode PlacementMode
Imports bool Imports bool
Filesystem *FilesystemContext Filesystem *FilesystemContext
} }
@@ -18,8 +27,14 @@ type Underlay struct{ Inputs []Input }
// LayeredOptions selects lower-priority tiers and final application rules. // LayeredOptions selects lower-priority tiers and final application rules.
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay // An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
// ignores it. Overrides remain explicit CLI records, not general mappings. // ignores it. Overrides remain explicit CLI records, not general mappings.
// SourceVersionMode applies one declaration policy to main, underlay, import,
// and selected overlay documents. Enabled modes interpret the version path
// source-locally, consume accepted non-main declarations, and protect main's
// destination-prefixed path. The zero value preserves existing behavior; see
// the shared source-version contract in docs/config.md.
type LayeredOptions[T any] struct { type LayeredOptions[T any] struct {
Definition Definition[T] Definition Definition[T]
SourceVersionMode SourceVersionMode
Underlays []Underlay Underlays []Underlay
Overlay string Overlay string
OverlayFilesystem *FilesystemContext OverlayFilesystem *FilesystemContext
@@ -44,15 +59,18 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T],
} }
func toEngineInput(input Input) engine.Input { func toEngineInput(input Input) engine.Input {
return engine.Input{ result := engine.Input{
Source: input.Source.toEngine(), Imports: input.Imports, Source: input.Source.toEngine(), Imports: input.Imports,
Filesystem: toEngineFilesystemContext(input.Filesystem), Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
} }
return engine.InputWithPlacement(result, uint8(input.PlacementMode))
} }
func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] { func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] {
result := engine.LayeredOptions[T]{ result := engine.LayeredOptions[T]{
Definition: toEngineDefinition(options.Definition), Overlay: options.Overlay, Definition: toEngineDefinition(options.Definition),
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
Overlay: options.Overlay,
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem), OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
} }
if options.Underlays != nil { if options.Underlays != nil {

View File

@@ -15,17 +15,83 @@ type Definition[T any] struct {
Validate func(*T, *Validation) Validate func(*T, *Validation)
Version *VersionRule Version *VersionRule
Secret func(Path) bool Secret func(Path) bool
// SequenceCompositions registers mapping paths whose sequence contributions
// strictly append distinct records by the selected literal identity field.
// Invalid or conflicting registrations fail option validation. Repeated input
// identities fail strict composition after source and helper processing.
// Registration paths are copied when a load begins; definitions are read-only
// after being shared.
SequenceCompositions []SequenceComposition
} }
// PlacementMode selects how an input mapping is placed at its Destination.
// The zero value, PlacementModeMapping, preserves ordinary mapping placement.
// Unknown values and sequence-item placement at the root fail option validation.
type PlacementMode uint8
const (
// PlacementModeMapping places a source mapping at Destination.
PlacementModeMapping PlacementMode = iota
// PlacementModeSequenceItem wraps a source mapping as one item at Destination.
PlacementModeSequenceItem
)
// SequenceComposition registers a sequence path for strict identity-based
// composition. Path is copied at load start; IdentityField is one literal key,
// including the empty string. Invalid or conflicting registrations fail option
// validation.
type SequenceComposition struct {
// Path selects a literal mapping path; registration paths are copied at load
// start. Invalid paths fail option validation; the root path is harmless.
Path Path
// IdentityField is one literal mapping key and may be empty. Records must
// contain a string at this key; malformed records fail strict composition.
IdentityField string
}
// SourceVersionMode controls schema-version declarations across one complete
// load. The zero value preserves the existing main-only effective-path check
// and its protected-path behavior. Enabled modes check participating documents
// at source-local Definition.Version.Path coordinates and consume accepted
// non-main declarations. See the shared source-version contract in docs/config.md.
type SourceVersionMode uint8
const (
// SourceVersionModeDefault preserves the existing main-version check and
// rejects version-path contributions from imported, underlay, and overlay
// payloads.
SourceVersionModeDefault SourceVersionMode = iota
// SourceVersionModeIfPresent checks each participating document that has a
// declaration. A missing main declaration still reaches the checker; an
// absent non-main declaration skips it. Accepted non-main declarations are
// consumed before composition.
SourceVersionModeIfPresent
// SourceVersionModeRequired requires a declaration in each participating
// document. NoSource retains the existing absent-main checker behavior.
// Accepted non-main declarations are consumed before composition.
SourceVersionModeRequired
)
// Options contains the application definition and per-load input selections. // Options contains the application definition and per-load input selections.
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay; // Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
// Overrides contains only values explicitly supplied by an application CLI. // Overrides contains only values explicitly supplied by an application CLI.
// Enabled source-version modes interpret Definition.Version.Path in each
// document's local coordinates. Placement projects checks and main protection
// through the input destination; item placement adds the source's item
// coordinate to that projection. See docs/config.md for the full contract.
type Options[T any] struct { type Options[T any] struct {
Definition Definition[T] Definition Definition[T]
// Destination places the ordinary source beneath literal mapping keys. Its
// zero value means the application root. Invalid paths fail before acquisition.
Destination Path
// PlacementMode defaults to mapping placement; unknown values and root item
// placement fail before acquisition.
PlacementMode PlacementMode
Imports bool Imports bool
Overlay string Overlay string
Filesystem *FilesystemContext Filesystem *FilesystemContext
Overrides []Override Overrides []Override
SourceVersionMode SourceVersionMode
} }
// FilesystemContext supplies roots for imports and overlays. With FS nil, // FilesystemContext supplies roots for imports and overlays. With FS nil,
@@ -53,7 +119,10 @@ type Override struct {
type Resolution struct{ value *engine.Resolution } type Resolution struct{ value *engine.Resolution }
// VersionRule declares the input path and application checker for a schema version. // VersionRule declares the input path and application checker for a schema version.
// At that path, string and decimal-integer input is supplied as Version.Text. // Default source-version mode interprets Path as the existing effective path;
// enabled modes interpret it in each participating document's local coordinates.
// At the selected path, strings and strict decimal integers supply their exact
// spelling as Version.Text. See docs/config.md for the complete contract.
type VersionRule struct { type VersionRule struct {
Path Path Path Path
Check func(Version, *Validation) error Check func(Version, *Validation) error
@@ -229,7 +298,9 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
Definition: toEngineDefinition(options.Definition), Definition: toEngineDefinition(options.Definition),
Imports: options.Imports, Imports: options.Imports,
Overlay: options.Overlay, Overlay: options.Overlay,
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
} }
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
if options.Filesystem != nil { if options.Filesystem != nil {
result.Filesystem = toEngineFilesystemContext(options.Filesystem) result.Filesystem = toEngineFilesystemContext(options.Filesystem)
} }
@@ -258,6 +329,15 @@ func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemCon
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] { func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
result := engine.Definition[T]{Defaults: definition.Defaults} result := engine.Definition[T]{Defaults: definition.Defaults}
if definition.SequenceCompositions != nil {
paths := make([]engine.Path, len(definition.SequenceCompositions))
identityFields := make([]string, len(definition.SequenceCompositions))
for index, registration := range definition.SequenceCompositions {
paths[index] = toEnginePath(registration.Path.clone())
identityFields[index] = registration.IdentityField
}
result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields)
}
if definition.Resolve != nil { if definition.Resolve != nil {
result.Resolve = func(configuration *T, resolution *engine.Resolution) error { result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
return definition.Resolve(configuration, &Resolution{value: resolution}) return definition.Resolve(configuration, &Resolution{value: resolution})

View File

@@ -182,16 +182,48 @@ type Location struct {
// SourceInfo describes a source discovered during a load. Kind identifies its // SourceInfo describes a source discovered during a load. Kind identifies its
// acquisition, independent of Layer. Path is canonical for a host file and // acquisition, independent of Layer. Path is canonical for a host file and
// namespace-relative for a filesystem file; synthetic sources have no Path. // namespace-relative for a filesystem file; synthetic sources have no Path.
// Destination is the effective mapping path where this source contributes.
// UnderlayIndex is the one-based original underlay position, or zero for other // UnderlayIndex is the one-based original underlay position, or zero for other
// sources. ImportedFrom identifies the declaration that included this source. // sources. ImportedFrom identifies the declaration that included this source.
type SourceInfo struct { type SourceInfo struct {
ID string ID string
Label string Label string
Path string Path string
// Destination is the effective mapping or sequence-container path. It is a
// detached snapshot. A fallback container is marked by DestinationUnresolved.
Destination Path
// PlacementMode describes how this source was placed. Mapping is the zero
// value; CLI and overlay sources report mapping placement unless item placement
// was applied to that source.
PlacementMode PlacementMode
// DestinationUnresolved marks Destination as the nearest resolved enclosing
// container when an enclosing item index is not yet assigned. It is false for
// exact destinations, including an item source's own sequence container.
DestinationUnresolved bool
Layer Layer Layer Layer
Kind SourceKind Kind SourceKind
UnderlayIndex int UnderlayIndex int
ImportedFrom *Location ImportedFrom *Location
// VersionCheck is sanitized evidence for source-local declaration processing
// in an enabled source-version load. It is nil when that processing did not
// occur, including default mode and synthetic CLI sources. During callbacks
// and failed loads, text is conservatively withheld; successful final source
// records reflect completed sensitivity classification.
VersionCheck *SourceVersionCheck
}
// SourceVersionCheck describes a source-local version declaration observed
// during an enabled load. A non-nil record with Present false records observed
// absence, but does not imply that a checker ran or accepted a value. Text is
// empty when sensitivity classification or conservative callback-time/failed-
// load redaction withholds it; Present and Null retain their meaning. Copies
// returned by source queries are detached. See docs/config.md for disclosure
// and lifecycle details.
type SourceVersionCheck struct {
Path Path
Version Version
Location *Location
Redacted bool
} }
// Diagnostic is a safe structured problem report. Message and source labels must // Diagnostic is a safe structured problem report. Message and source labels must
@@ -260,10 +292,22 @@ func cloneLocations(locations []Location) []Location {
} }
func cloneSourceInfo(source SourceInfo) SourceInfo { func cloneSourceInfo(source SourceInfo) SourceInfo {
source.Destination = source.Destination.clone()
source.ImportedFrom = cloneLocation(source.ImportedFrom) source.ImportedFrom = cloneLocation(source.ImportedFrom)
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
return source return source
} }
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
if value == nil {
return nil
}
cloned := *value
cloned.Path = value.Path.clone()
cloned.Location = cloneLocation(value.Location)
return &cloned
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo { func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil { if sources == nil {
return nil return nil

View File

@@ -1,6 +1,10 @@
package configkit package configkit
import "testing" import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestOverlayPublicValues(t *testing.T) { func TestOverlayPublicValues(t *testing.T) {
if LayerOverlay != "overlay" { if LayerOverlay != "overlay" {
@@ -20,3 +24,50 @@ func TestOverlayPublicValues(t *testing.T) {
} }
} }
} }
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
destination := Keys("collections", "a/b", "0")
public := SourceInfo{
ID: "source-1", Destination: destination,
PlacementMode: PlacementModeSequenceItem, DestinationUnresolved: true,
}
internal := toEngineSourceInfo(public)
if internal.Destination.String() != destination.String() || engine.SourceInfoPlacementModeForFacade(internal) != uint8(PlacementModeSequenceItem) || !engine.SourceInfoDestinationUnresolvedForFacade(internal) {
t.Fatalf("engine source evidence = %#v", internal)
}
converted := fromEngineSourceInfo(internal)
if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || !converted.DestinationUnresolved {
t.Fatalf("public source evidence = %#v", converted)
}
copy := cloneSourceInfo(converted)
copy.Destination = Path{}
if converted.Destination.String() != destination.String() {
t.Fatalf("source record destination changed through clone: %q", converted.Destination.String())
}
}
func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
path := Keys("records").Index(2).Key("version")
source := SourceInfo{
ID: "source-1",
VersionCheck: &SourceVersionCheck{
Path: path, Version: Version{Present: true, Text: "123"},
Location: &Location{SourceID: "source-1", Line: 4, Column: 8},
},
}
internal := toEngineSourceInfo(source)
if internal.VersionCheck == nil || internal.VersionCheck.Path.String() != path.String() ||
internal.VersionCheck.Version.Text != "123" || internal.VersionCheck.Location == nil || internal.VersionCheck.Location.Line != 4 {
t.Fatalf("engine source version check = %#v", internal.VersionCheck)
}
converted := fromEngineSourceInfo(internal)
copy := cloneSourceInfo(converted)
copy.VersionCheck.Path = Keys("changed")
copy.VersionCheck.Version.Text = "changed"
copy.VersionCheck.Location.Line = 99
copy.VersionCheck.Redacted = true
if converted.VersionCheck.Path.String() != path.String() || converted.VersionCheck.Version.Text != "123" ||
converted.VersionCheck.Location.Line != 4 || converted.VersionCheck.Redacted {
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
}
}