73 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
86 changed files with 9502 additions and 1597 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
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
type Config struct {
Enabled bool `yaml:"enabled"`
@@ -62,17 +69,23 @@ children; other values replace lower-priority values. The main document and its
imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay and explicit CLI overrides sit
above it. The [configuration contract](docs/config.md#strict-document-composition)
owns the detailed combination rules and input-specific constraints.
owns the detailed combination rules and input-specific constraints. At
definition-registered paths only, strict composition can append records with
distinct explicit IDs; ordinary and unregistered sequences keep their conflict
or whole-value replacement behavior.
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
these priorities and how genuine new needs can justify extending the shared
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
consideration in future releases.
The [configuration contract](docs/config.md) describes implemented behavior,
including [input destinations](docs/config.md#input-destinations). The
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
layered, and mounted-collection examples alongside the migration shapes. The
accepted placement decision is recorded in [ADR 0011](docs/adr/0011-input-destinations.md).
layered, mounted-collection, and identified-prompt examples alongside the
migration shapes. Input placement decisions are recorded in
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
See the [development guide](docs/development.md) for repository orientation and
the [release procedure](docs/release.md) for maintainer-only candidate and
publication steps.

View File

@@ -43,10 +43,31 @@ func toEngineLocations(values []Location) []engine.Location {
return result
}
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), 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 {
return 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)}
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 {
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}

View File

@@ -2,9 +2,13 @@
## Status
Accepted. The [input-destinations roadmap](../roadmap/input-destinations.md)
owns implementation status and the [implementation plan](../roadmap/implementation.md)
owns sequencing and completion evidence.
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

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

View File

@@ -17,9 +17,11 @@ sanitized shapes using only ConfigKit and the Go standard library:
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
@@ -53,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
execution profile concepts.
## Shared source-version checks
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized
`SourceInfo.VersionCheck` fields; its executable test is in
[`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption,
main-path protection, default behavior, and evidence disclosure. Accepted
declarations do not replace final assembled decoding or application validation.
## Effective configuration diffs
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
@@ -109,12 +123,21 @@ and may fail independently of the completed configuration load. The
[configuration contract](../config.md#source-resource-contexts) owns exact
lifetime and precedence rules.
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
counterpart: its entry opts into imports through `Load`, imports metadata at the
root, and places a prompt beneath `prompts` using YAML. The executable example
and behavior test are in
[`layered_test.go`](../../examples/consumers/layered_test.go).
## Input destinations and mounted collections
Main and underlay inputs use the same destination field; imported application
contributions inherit the declaring input's destination. A destination is an
application mapping path, independent of filesystem context. The configuration
contract owns the complete behavior and validation rules.
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
@@ -123,12 +146,13 @@ default. Consumers pass paths, not filenames or namespace strings for ConfigKit
to interpret.
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
embedded collection manifest with a source-root import, separate prompt and
profile peers, and a higher-priority input that 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.
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
@@ -140,6 +164,24 @@ 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
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-

View File

@@ -40,9 +40,10 @@ public behavior.
| --- | --- |
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
| Documentation changes | The owning document |
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
| Code and test changes | The owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release procedure](release.md) |
@@ -50,6 +51,7 @@ public behavior.
Accepted decisions:
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
@@ -106,7 +108,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
```
Remaining optimization candidates belong to
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
[future performance experiments](roadmap/future.md#3-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under
@@ -115,6 +117,39 @@ resolved review findings are preserved in Git history at `1c2610c` under
comparison and disclosure behavior, and
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
The completed input-destination, relative-import-destination, and shared
source-version roadmaps, associated consumer request, implementation plan, and
resolved review findings are preserved in Git history at `e4270c9` under
`docs/roadmap/`. For example:
```sh
git show e4270c9:docs/roadmap/implementation.md
git show e4270c9:docs/roadmap/remediation.md
```
Current behavior belongs to the configuration contract's
[input destinations](config.md#input-destinations),
[imports](config.md#explicit-imports), and
[shared source-version checks](config.md#shared-source-version-checks) sections.
Explicit sequence-item placement and registered strict composition are owned by
the [strict composition](config.md#strict-document-composition) and
[imports](config.md#explicit-imports) sections. The
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
records the decision.
The completed sequence-composition roadmap, implementation plan, and resolved
remediation record are preserved in Git history at `008d2ba` under
`docs/roadmap/`. For example:
```sh
git show 008d2ba:docs/roadmap/sequence-composition.md
git show 008d2ba:docs/roadmap/implementation.md
git show 008d2ba:docs/roadmap/remediation.md
```
Potential enhancements are tracked in [future features](roadmap/future.md).
Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline
development or validation environment:
@@ -161,19 +196,20 @@ code changes. Use the component overview to find the corresponding implementatio
| Task | Behavioral test owners | Focused command |
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
| Input destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier and import tests](../internal/engine/destination_tiers_test.go), [integration and provenance tests](../internal/engine/destination_integration_test.go), [public facade tests](../facade_destination_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Mounted'`, `go test . -run 'Destination|FacadeMounted'`, and `go test ./examples/consumers -run 'CollectionBundle|CollectionPeer'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| 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` |
| 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'` |

View File

@@ -9,8 +9,13 @@ Implemented component owners:
- `internal/engine` owns host and native-filesystem source acquisition,
grouped strict composition, recursive precedence assembly, per-input mapping
placement within that assembly, and destination-local sibling inheritance
before typed decoding. It retains source placement alongside physical source
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,
provenance/history, passive source-context retention, secret resolution and
@@ -43,8 +48,13 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics, including [input destinations](../config.md#input-destinations) and
semantics, including [input destinations](../config.md#input-destinations),
[shared source-version checks](../config.md#shared-source-version-checks), and
[effective configuration diffs](../config.md#effective-configuration-diffs).
The [strict composition contract](../config.md#strict-document-composition)
owns sequence placement, registration, replacement, and history behavior; the
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
maintained example.
The [consumer adoption guide](../consumers/README.md) owns application guidance.
The [architecture policy](../policy/architecture.md) owns contributor
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
@@ -53,4 +63,8 @@ decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
decision. See the [development guide](../development.md) for validation commands
and [release procedure](../release.md) for source-candidate gates. [ADR
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
post-assembly structural operation.
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
records the source-version decision.
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
sequence placement and composition.

View File

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

View File

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

View File

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

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

@@ -1,580 +0,0 @@
# Implement configuration input destinations
Status: complete. Stages 1–12 are implemented and audited. The
[feature roadmap](input-destinations.md) owns the approved behavior and rationale; the
[consumer request](promptkit-feature-request.md#public-acceptance-scenarios)
supplies the public acceptance scenarios. This document owns execution and
completion evidence. Planning baseline: `6353598`.
## Working instructions
- Start each stage with `AGENTS.md`, [development.md](../development.md), the
applicable policies, and working-tree inspection. Use graph-first code
discovery. Preserve unrelated changes and previously completed stages.
- Implement only that stage and its supporting tests/documentation. Mark it
complete here with actual commands and evidence, then commit the nonempty
validated unit with a short plain-English message. Subsequent stages remain
planned. A stage is not complete solely because its narrow tests pass.
- Keep early mechanics private until the complete engine behavior is ready.
Root destination fields land in Stage 10. Ordinary root loading must remain
working throughout. Do not publish a partially supported public placement API.
- Follow the existing engine/document/YAML boundaries. Reuse strict composition,
recursive overlay, sibling inheritance, paths, history, and sensitivity.
No dependencies, toolchain changes, alternate mergers, mount registry,
per-fragment lifecycle, import-destination syntax, resource discovery, or new
release gates are needed. These are scope boundaries, not new prohibitions
on separately approved future work.
- Update current-behavior documentation when public behavior lands; keep future
claims here or in the roadmap until then. Add the ADR in Stage 1 under the
documentation policy's accepted-decision exception.
- Use focused offline tests while iterating; run `./scripts/check.sh` before
committing production changes. Documentation-only stages need whitespace and
local-link checks. Stage 12 adds full ownership/race validation. Tests protect
observable behavior, not wrapper allocation or helper-call counts. Reuse
coverage rather than repeat an entire loading matrix at every layer.
- No benchmark campaign or public complexity guarantee is required. Prefer
straightforward bounded walks and wrapping each acquired contribution once;
avoid rescanning all preceding contributions for every new input.
- Do not push, tag, publish, update downstream dependencies, or retire unfinished
records. Release compatibility review is separate from publication.
## Resolved implementation decisions
1. Add root-owned `Input.Destination Path` and `SourceInfo.Destination Path`,
mirrored privately in engine input/source records. Extend `loadInput` and
explicit root/engine conversion and cloning. No new loading entry point,
public wrapper type, option, or origin field is needed. Ordinary `Load`
continues to supply root placement.
2. The zero path means root. Validate `Path.valid()` and reject every explicit
index segment, including a negative index. Normalize parsed tokens to literal
mapping-key segments: `/0` means the key `0`, not sequence element zero.
Empty, dot, dot-dot, slash-containing, tilde-containing, and numeric keys keep
existing semantic-path meaning. Do not split dots, interpret filesystem
traversal, enforce identifier grammar, or add namespace uniqueness.
3. Reject destination segments equal to `configkit` or `extends`: placement
would introduce a reserved mapping key with a mapping value. Substrings such
as `myextends` are ordinary keys. This preserves the approved structural
rules rather than adding an application naming policy. Source-root import
directives are still processed before placement.
4. Validate destination shape/reservations for all declared inputs during
grouped option preflight, before reading any source. Reuse
`CodeOptionsInvalid` for invalid destination declarations; include the
offending semantic path when representable, with no fabricated physical
location. An invalid source still follows existing source-option validation.
A valid destination on `NoSource` contributes no nodes, source record, or
budget charges. No extra error type or exact message contract is introduced.
5. Separate acquired source syntax from application placement. Retain original
parsed source metadata for filesystem/directive handling. Strip source-root
directives, then wrap each application body at the normalized destination
before fragment version checks and strict composition. Imports inherit the
same destination transitively. Do not wrap both a source application and its
recomposed result; every retained application must already use effective
coordinates. Content roots remain mappings under existing source contracts.
6. Wrapping a body at `Keys("a", "b")` creates `{a: {b: body}}` as private
document mappings. Actual body nodes and entries keep their physical source
coordinates. Introduced mappings/entries use the contributing document's root
location as a documented source fallback, not purported authored coordinates.
They have the same contributing source ID and create no synthetic source.
Retained history trees remain immutable and separate from assembly mutation.
7. A present empty body creates explicit destination mappings, including an
import-only source whose application body is empty. It still cannot clear
lower mappings. A missing optional import and `NoSource` remain absent. Empty
source text remains the existing invalid document, not an empty mapping.
8. `VersionRule.Path` stays an effective application path. Check main once after
its own imports compose and before underlays/overlay/CLI, at the existing
phase. Imported and underlay claims are checked against placed application
content, including scalar/null/sequence ancestors replacing a protected path.
A mapping ancestor with no protected descendant is not a version claim.
Mounted domain `version` fields are ordinary data. Existing inheritance,
selected-overlay, CLI protection and exact-path numeric conversion remain;
no nested blanket conversion or additional fragment checker is introduced.
9. Placement does not alter physical source identity, filesystem namespace,
source IDs, acquisition order, imported-from locations, or resource contexts.
Repeated physical sources in one strict group still fail even at different
destinations. The same physical source in separate tiers retains the existing
independent source applications/records and each record's own destination.
Overlay and CLI source records have root `Destination`; they are not mounted
merely because main is mounted. Active layered overlays retain their explicit
independent filesystem selection and application-root meaning.
10. Value-level paths use effective coordinates. Keep genuine source-local
import-directive diagnostics and parse/read failures at their original
locations; do not invent a logical child path when parsing cannot identify
one. Prefix existing application-relative diagnostic paths exactly once
where available. Preserve structured codes, related physical locations,
deterministic ordering, and value-free messages.
11. Use current depth/node limits, not a new destination cap. With root depth
zero, each destination segment adds one level and, under `countInputNodes`,
one mapping node plus one key. Retain current raw input accounting (including
directives); charge only the additional `2 * segmentCount` synthetic nodes
per acquired body, once, to the shared input budget. Placement adds no bytes,
acquired sources, import nesting, or glob visits. Validate placed application
depth and per-document node bounds before composition; share accounting
across inputs and imports. Precheck wrapper depth/count before allocating
wrappers. Use existing source/import limit diagnostics in their acquisition
contexts; retain projection, expansion, and history bounds independently.
Root placement adds no charge or new rejection. NoSource adds none either.
12. Cloned source records include independently owned destination paths at every
existing publication boundary: results, load errors, metadata/inspection,
callback input information, and root conversion. Passive resource contexts
remain keyed by the real source identity; inspection must work without an
open configuration handle after load. This does not extend caller-owned
filesystem or callback resource lifetimes.
## Stage 1 — Record the decision and capture the baseline
Status: complete. Scope: decision record and evidence, no feature code.
1. Read the roadmap, request, relevant policies, and ADRs 0001, 0003, 0006,
0008, and 0009. Inspect the owners identified in the roadmap and the function
map below. Confirm the baseline and worktree before starting.
2. Run `./scripts/check.sh`; record the commit, Go version, and results here.
Locate existing coverage for root loading, strict underlays, version ownership,
repeated sources, history, resource contexts, and detached records.
3. Write `docs/adr/0011-input-destinations.md` after confirming the number is
unused. Record the approved input-placement decision as Accepted, with the
roadmap owning implementation status. Follow the repository ADR format.
Focus on ConfigKit, put rejected options only in Alternatives Considered,
and avoid negative parallelism elsewhere. No PromptKit-specific semantics.
4. Record the compatibility assessment under the release policy: adding fields
preserves zero-value/keyed-literal use, but callers using unkeyed literals
for `Input` or `SourceInfo` must migrate to keyed literals. Treat the feature
as minor-release work; select a version and write its note only during
separately authorized release preparation.
Validated: `./scripts/check.sh` (go1.26.8 linux/amd64) passed on candidate
`d3d468a8b47d780787fca329ff65b107d98ca59f`; this includes formatting,
whitespace, local documentation links, tests, vet, and build. The planning
baseline `6353598bd6cce2eb78a5fe990e144cc0cadf1280` is present in history. The
worktree was clean before this stage.
Coverage located for later destination work:
- Root and equivalent source loading: `TestEquivalentSourceFormsPreserveInputSemantics`
(`facade_equivalence_test.go`) and
`TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad` (`facade_layered_test.go`).
- Strict underlays and group budgets: `TestGroupedLoadStrictConflictsRepetitionAndBudgets`
and `TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle`
(`internal/engine/load_request_test.go`).
- Version ownership and imported claims: `TestLoadRejectsImportConflictsAndFragmentVersions`
(`internal/engine/imports_test.go`) and version-path coverage in
`internal/engine/load_test.go`.
- Repeated physical sources: `TestRepeatedImportsAcrossTiersAndReaderConsumption`
(`facade_equivalence_test.go`).
- History and source contexts: `TestUnderlayHistoryRetainsLaterMappingContributors`
(`facade_underlay_origins_test.go`) and
`TestFacadeSourceContextsFollowOriginsWithoutQueryIO`
(`facade_resource_context_test.go`).
- Detached records: `TestLoadMetadataReflectsDefaultsInputAndDetachedRecords`
(`internal/engine/load_test.go`) and
`TestMetadataQueriesReturnDetachedRecords` (`internal/engine/metadata_test.go`).
The decision is recorded in [ADR 0011](../adr/0011-input-destinations.md).
Compatibility assessment: adding the two public fields breaks callers using
unkeyed literals for `Input` or `SourceInfo`; keyed and zero-value use remains
compatible. The release policy permits the intentional pre-1.0 contract change
in a minor release when the release note states impact and migration. Version
selection and release-note preparation remain out of scope until separately
authorized. No feature code or current behavior contract changed.
## Stage 2 — Add private destination normalization and wrapping
Status: complete. Scope: pure private mechanics with bounded unit tests.
1. Add a small engine helper file for normalized mapping-only destinations and
constructing placed document bodies. Follow decisions 2–7; keep helpers
private and avoid introducing a second path abstraction.
2. Validate invalid paths, explicit indices, and exact reserved tokens. Normalize
parsed numeric tokens into keys, preserving literal text and pointer escaping.
3. Build wrappers without mutating the supplied tree. Preserve body locations;
give introduced entries/mappings the source-root fallback. Implement depth and
node measurement/prechecks usable with existing limits, including the extra
synthetic-node count; aggregate charging is wired in Stage 7.
4. Cover root identity/equivalence, nested empty mapping, literal/escaped/numeric
keys, invalid index/path, exact versus substring reserved keys, owned paths,
unchanged body coordinates, and bounded wrapper checks. Do not count helper
invocations or fix an allocation strategy in tests.
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. The private
helpers normalize mapping-only destinations, preserve authored nodes and source
coordinates, and check per-document depth/node bounds before allocating wrappers.
No loader accepts a new public field yet.
## Stage 3 — Integrate private non-import input placement
Status: complete. Scope: engine request plumbing and ordinary acquired bodies.
1. Add destination fields to engine `Input`, `loadInput`, and `SourceInfo`.
Preserve ownership through engine `LoadLayered` request construction,
`cloneSourceInfo`, and source-record copies. Keep the root facade unchanged.
2. Extend grouped preflight to validate/normalize destinations before source
reads. Use the existing diagnostic accumulator and options-invalid category.
Ordinary `Load` supplies root placement through its existing request adapter.
3. Thread placement into `assembleBaseInputTracked` without confusing raw
`parsedSource` with effective `assembledInput.document`. Preserve the root
path through existing wrappers/callers. For imports-disabled acquisition,
validate disabled directives on raw content, place the body, and retain the
destination on the acquired source record. NoSource still short-circuits.
4. Use engine loads to cover mounted main and underlay bodies, omitted versus
explicit root destinations, empty mapping versus NoSource, and preflight
rejection without consuming a test-owned reader. Reuse current source-kind
acquisition tests; full source equivalence is verified publicly in Stage 10.
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Grouped'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Private non-import
placement reaches assembly, with root behavior unchanged. Imports are the next
private integration step.
## Stage 4 — Carry placement through import acquisition
Status: complete. Scope: source-local directives and placed imported applications.
1. Thread a normalized destination through `loadImportedDocumentsTracked`,
`loadImportedWithAccessTracked`, and `importTraversal`, retaining root defaults
for current private callers. Place the selected body's application content
after `splitImportDirectives`; do the same for every imported body.
2. Set placement on selected/imported source records, including error records
once a source has been acquired. Keep `ImportedFrom` at the physical directive.
Run imported `fragmentVersionClaim` against placed bodies and retain placed
bodies in `state.documents` before `composeDocuments` builds applications.
3. Leave filesystem joins, containment, glob traversal, optional missing inputs,
active cycles, visited identities, and acquisition IDs/order unchanged.
Destination must not become part of the repeated-source identity key.
4. Test native and host transitive imports, nested source-root directives,
import-only bodies, and mounted imported protected versus ordinary version
paths. Verify a repeated file at two destinations in one group still fails,
including an import/direct-input alias. Reuse cycle/containment regressions.
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Import|Grouped'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Retained imported
contributions have effective placement and physical source identity, with no
double wrapping or application-level directive leakage.
## Stage 5 — Complete strict tiers and main version ownership
Status: complete. Scope: assembly and version interactions, not a new merger.
1. Review `acquireUnderlayGroup`: version checks must use placed content, and
documents taken from `acquired.composition.applications` must already be
placed. Recompose strict peers with ordinary `composeDocuments`; preserve
per-source applications, origins, UnderlayIndex, and input accounting.
2. In `executeLoadRequest`, main's version check uses its placed main/import
composition at the existing point. `assembleLayeredInput` recursively overlays
completed underlay groups beneath main, selected overlay, and CLI as before.
Do not mount the final assembly or prepend main's destination to upper tiers.
3. Test disjoint same-destination peers, different destinations, equal and unequal
conflicts, ancestor/descendant destinations, and a strict conflict despite an
upper override. Verify recursive mapping overlays, atomic sequences, null,
explicit false/zero, and version-only domain overrides retaining lower fields.
4. Exercise mounted main with a nested VersionRule, absent root declarations,
imported/underlay direct and ancestor claims, and nonclaiming mapping ancestors.
Check the hook runs once and does not inspect each fragment independently.
Preserve exact configured-path integer conversion and ordinary nested typing.
Validate: destination/composition/overlay/version/grouped tests and everyday
check. Validated with `go test ./internal/engine -run 'LayeredDestination'`,
`go test ./internal/engine -run 'Destination|Composition|Overlay|Version|Grouped'`,
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The new mounted
cases verify strict same-tier conflicts and disjoint composition, independent
destinations and nested paths, recursive/atomic overlay behavior, explicit
null/false/zero, and main-only nested version checking after imports compose.
Done: placed and authored equivalents agree on values and conflicts; main
ownership and lifecycle timing are preserved.
## Stage 6 — Reconcile diagnostic coordinate systems
Status: complete. Scope: useful paths without fictional source locations.
1. Audit early acquisition, import, fragment-version, and composition failures.
Use one narrow path-prefix translation where diagnostics genuinely identify
application content; do not blanket-prefix every diagnostic or double-prefix
diagnostics already produced by placed trees.
2. Preserve syntax/read/directive locations and related import sites. Value
conflicts and later typed/resolver/validation failures use effective paths
but original content coordinates. Invalid introduced structure identifies
the destination declaration rather than a nonexistent source wrapper line.
3. Add focused assertions for a mounted conflict with two real sources, a mounted
decode or validation error, an imported version error, invalid destination,
and malformed source-local directives. Test stable codes/paths/locations and
nil results, not whole messages or callback choreography.
Validate: destination/diagnostic/import/version/decode tests and everyday check.
Validated with `go test ./internal/engine -run 'LayeredDestinationDiagnostics'`,
`go test ./internal/engine -run 'Destination|Diagnostic|Import|Version|Decode'`,
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The mounted
conflict, decode, and imported-version assertions use effective application
paths with authored source locations; invalid destinations have no physical
location; malformed source-root directives retain their authored path and
location. Later value diagnostics are produced from placed trees, while directive
diagnostics retain their raw source coordinates. Post-review remediation also
rebases known Mapping value-builder failure paths, which are produced before
placement. `TestFacadeDestinationMappingFailurePaths` covers mounted main and
underlay failures, escaped keys and sequence-index intent, cycles, unchanged
root paths, and unchanged source-constructor failures. Its mounted value cases
failed before the fix and pass afterward.
Done: diagnostics consistently distinguish application paths from source syntax.
## Stage 7 — Complete shared placement accounting
Status: complete. Scope: resource bounds with no new public limit system.
1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary
acquisition and import traversal, using `importAccounting` and current limit
structures. Charge each source body once, including empty import bodies.
2. Preserve existing raw byte/source/node accounting and imported directive
inspection. Additional wrappers count in per-body depth/node checks and
shared aggregate nodes; recomposition must not charge them again. Ensure
selected overlays and CLI start from totals including placement charges.
3. Test below/at/above relevant bounds using small private limit fixtures where
existing seams permit; add a narrow private seam if necessary. Cover deep
body plus destination, many shallow mounted inputs, imported wrappers, and
empty mappings. Verify root placement and NoSource do not add charges.
Preserve history/expansion limits; do not turn fixture values into defaults.
Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in
`TestDestinationPlacementCountsAgainstSharedNodeBudget`,
`TestMultipleMountedInputsSharePlacementCharges`,
`TestImportedEmptyBodiesChargeEachDestinationWrapper`,
`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and
`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and
above the boundary, multiple shallow inputs, imported empty bodies, and charging
of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues
to cover per-document depth/node boundaries; existing grouped accounting tests
cover unchanged raw source/byte charging. Done: placement cannot bypass current
budgets and introduces no unrelated caps.
## Stage 8 — Preserve provenance, history, and source resource contexts
Status: complete. Scope: engine inspection and retained evidence.
1. Audit `compositionOrigins`, `completeCompositionOrigins`, application replay,
metadata construction/finalization, and source-record cloning against placed
applications. Prefer feeding placed trees into existing processing rather
than adding another path-offset index or rewriting final metadata afterward.
2. Verify explicit destination containers, actual leaves, composite origins,
superseded/removed descendants, and root/source fallbacks. Historical and
effective indexes keep independently owned paths and existing shape semantics.
3. Preserve source-ID resource contexts through imports, surviving lower-tier
values, and inheritance. Test completed-result and resolution-time context
queries against real relative files, without making ConfigKit open the
referenced application resource or retain loading handles.
4. Exercise the same physical source in separate tiers with different placement,
inspect each source record, and verify independent destination paths. Repeat
inspection after comparison and independent loads to detect retained mutation.
Validated with `go test ./internal/engine -run 'Destination|Metadata|Provenance|History|Resource|SourceContext'`,
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
linux/amd64); all passed. `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`
checks synthetic-container fallback locations, authored leaf coordinates,
composite origins, removed-descendant history, and the same host file retained
at distinct destinations in separate tiers before and after comparison and an
independent load. `TestLayeredImportedInheritedValuesRetainResourceContexts`
uses real temporary files to verify resolution-time and completed-result
relative-resource lookup through an imported surviving parent and its inherited
child. Existing metadata and resource-context tests continue to cover detached
records and loading-handle closure. Done: inspection explains effective
placement and physical origin without consumer reconstruction.
## Stage 9 — Verify final inheritance, sensitivity, and lifecycle integration
Status: complete. Scope: complete private engine behavior before facade exposure.
1. Resolve sibling inheritance only after full source precedence using the
existing expansion path. Cover a child and parent supplied by separate peers,
nested destination-local siblings, cancellation, and an inherited relative
resource retaining its declaring context. Cross-collection search is unchanged.
2. Reuse existing inheritance-version, overlay, and CLI guards; add targeted
mounted cases for actual protected paths and their ancestors. Do not reject
unrelated inheritance merely because it shares a mapping ancestor.
3. Test effective-path secret predicates, field tags and recorded dependencies,
superseded classified values, safe effective output/history/explanation, and
bilateral diff redaction using disposable canaries. Compare mounted versus
explicitly wrapped loads for equal effective values and no differences.
4. Verify defaults, decoding, resolution, secret lookup, and validation execute
once on the final shape; failures return no usable result. Reuse existing
ordinary-load lifetime/ownership coverage and add mounted protection where
placement creates a distinct risk.
Validate: destination/inheritance/secret/sensitivity/explain/diff/lifecycle tests
and everyday check. Validated with
`go test ./internal/engine -run 'Destination|Inheritance|Secret|Sensitivity|Explain|Diff|Load|Version|Grouped' -count=1`,
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
linux/amd64); all passed.
`TestLayeredDestinationInheritanceUsesFinalSiblingScopes` verifies parent and
child contributions from separate peers, a higher-tier parent update, nested
destination-local siblings, and cancellation. `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`
keeps sibling lookup local. `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers`
protects inherited values, scalar/null ancestors, consumed directives, selected
overlays, and CLI overrides at mounted paths while allowing nonclaiming mapping
ancestors and unrelated inheritance. The coverage exposed an ancestor gap in
the existing inheritance-version check; it now applies the shared fragment claim
logic to the transferred parent subtree.
`TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape` checks one
defaults/resolution/secret-lookup/validation lifecycle, final mounted decoding,
field tags and effective-path predicates, superseded history, and safe effective
output/explanation against canaries; it compares mounted and explicitly wrapped
loads for equal configuration and no diff. `TestLayeredDestinationDiffUsesBilateralSensitivity`
checks redaction from either side and recorded dependencies, and
`TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` checks a
mounted decode failure returns no result. Stage 8's
`TestLayeredImportedInheritedValuesRetainResourceContexts` continues to verify
the declaring context for inherited relative resources. Done: approved engine
semantics integrate without per-fragment loading or a post-return processing
pass.
## Stage 10 — Expose the root-owned facade and public acceptance tests
Status: complete. Scope: the two public fields and complete consumer behavior.
1. Add `Input.Destination Path` and `SourceInfo.Destination Path` to root types.
Extend `toEngineInput`, `fromEngineSourceInfo`, `toEngineSourceInfo`, and all
source publication paths with owned conversion. Run existing public/import
boundary tests; expose no engine nodes, aliases, or bridge methods.
2. Document zero/root behavior, mapping-only paths, main/underlay parity,
inherited import placement, source-coordinate fallback, and source ownership
beside declarations. Update the configuration contract for implemented input
destinations, preserving canonical ownership of shared semantics.
3. Add external-package public-load coverage for equivalent host file, native
fs.FS file, byte, reader, and Mapping sources. Cover default/root destinations,
literal escaped/numeric keys, empty mapping, NoSource, and invalid declarations.
Include one public mounted main case, not just underlays.
4. Add representative public tests tying final configuration to metadata,
source placement, history, resources, callbacks, and safe diffs. Mutate returned
record copies and repeat queries/loads to confirm detachment. Reuse engine
matrices; public tests demonstrate supported integration rather than every
private branch. Confirm failed loads expose detached source destinations.
Validated with `go test . -run 'Destination|FacadeLayered|PublicFacadeBoundary|DependencyImportBoundary' -count=1`,
`go test -race ./...`, and `./scripts/check.sh` (go1.26.8 linux/amd64); all
passed.
`TestFacadeInputDestinationsAcrossSourceKinds` exercises host, native
filesystem, bytes, reader, and mapping inputs with literal escaped and
numeric-looking mapping keys. `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths`
covers omitted/empty root placement, empty mapping presence, absent `NoSource`,
invalid index/reserved destinations, and reader preflight. `TestFacadeImportedSourcesRetainInputDestination`
checks public transitive import placement. `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff`
ties mounted main/underlay values to metadata, both source destinations, callback
source context, relative-resource access during and after loading, history, safe
effective/explanation rendering, and bilateral diff redaction; it also mutates
returned records and confirms later queries and comparisons remain unchanged.
`TestFacadeFailedLoadExposesDetachedSourceDestination` verifies failure records,
and `TestSourceInfoDestinationConversionsAndCopies` covers root/engine source
record conversion. Existing public-boundary tests pass with the new root-owned
fields. Done: destination selection and inspection are available through the
supported ConfigKit import path.
## Stage 11 — Add maintained simple and collection-style consumer examples
Status: complete. Scope: executable adoption examples with consumer-owned types.
1. Add a minimal mounted-fragment example using `Input.Destination` in an
ordinary layered load. Show literal keys and root-default compatibility
without introducing consumer parsing or generic merge helpers.
2. Add one representative collection assembly under `examples/consumers`: peer
prompt/profile fragments, collection metadata with ordinary `version`, a
source-root import, local sibling inheritance, and an intentional higher-tier
version-only override retaining the body. Keep the application VersionRule
main-owned. Use a controlled native filesystem or temporary files.
3. Demonstrate effective-path inspection plus source-relative resource lookup
during resolution and from the result, including an inherited/surviving field.
Application code owns opening/interpreting the resource and any qualified IDs.
4. Add tests for final values, presence, actual source placement/coordinates,
resource context, and expected conflict behavior. Link maintained examples
from the consumer guide; add a small executable root example if useful for
discovery without duplicating the complete scenario.
Validated with `go test ./examples/consumers -count=1`,
`go test . -run '^Example' -count=1`, and `./scripts/check.sh` (go1.26.8
linux/amd64); all passed, including local documentation links.
`Example_inputDestination` demonstrates a literal mounted mapping path and an
ordinary root input without consumer-side parsing or merging.
`LoadCollectionBundle` and `TestCollectionBundleAssemblesMountedPeersAndResources`
exercise prompt/profile peer sources, imported collection metadata with an
ordinary version label, main-owned schema-version checking, local sibling
inheritance, and a higher-tier prompt version-only change that retains the rest
of its body. The resolver and `ReadCollectionPromptContent` follow direct and
inherited `content_file` provenance to the declaring native-filesystem context.
The test checks final values, explicit presence, source destinations and authored
coordinates, relative resources, and the inherited field's origin.
`TestCollectionPeerPromptConflictsRemainStrict` preserves expected conflict
behavior. The examples are linked from the consumer adoption guide. Done: simple
and representative consumers substantiate the same model without a compensating
structural pass.
## Stage 12 — Reconcile documentation and audit the complete implementation
Status: complete. Scope: final integration audit and evidence, not release work.
1. Update README navigation, consumer guidance, the component overview, and
development reading/test maps. Add ADR 0011 to navigation. Update architecture
policy only as needed to recognize placement within existing assembly and
source ownership, linking precise behavior to the configuration contract.
Do not change unrelated policies, deferred boundaries, or published notes.
2. Audit every resolved decision, roadmap behavior, and request acceptance row
against implementation and tests. Record the corresponding test names here;
for reused coverage explain the behavior it proves. Do not mark complete
based on green tests that do not exercise mounting.
3. Review the complete baseline-to-candidate diff for accidental strengthening:
extra naming rules, namespace uniqueness, main-only restrictions, changed
merge/version semantics, invented source-read ordering guarantees, new limits,
or expanded secret policies. Remove temporary helpers/unsupported branches
left by staged integration. Confirm original source coordinates remain real.
4. Run `./scripts/check.sh` and
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...`. Review source-kind and
placement ownership coverage, error disclosure, complete-on-return handling,
and root/private boundaries. Record commit, Go version, results, and any
limitations. No release command, CI waiting, benchmark, or downstream upgrade.
5. Mark this plan and the roadmap implemented only when code, public examples,
and owning contracts agree. Retain completed planning evidence until separately
authorized release preparation.
Audit evidence for the approved decisions and request acceptance surface:
| Behavior reviewed | Evidence and what it proves |
| --- | --- |
| Root-owned `Input.Destination` and `SourceInfo.Destination`; detached conversions and publication | `TestSourceInfoDestinationConversionsAndCopies`, `TestFacadeFailedLoadExposesDetachedSourceDestination`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` verify root/engine conversion, error records, and returned record detachment. `TestPublicFacadeBoundary` and `TestDependencyImportBoundary` verify no engine types cross the facade. |
| Literal mapping keys, parsed numeric keys, reserved structural tokens, and invalid-path preflight | `TestNormalizeDestinationUsesLiteralMappingKeys`, `TestNormalizeDestinationRejectsIndicesAndReservedKeys`, and `TestLayeredLoadPreflightsDestinationsBeforeReadingSources` cover mapping-only normalization and rejection before reader consumption; `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths` verifies the public error boundary. |
| Placement of acquired bodies, empty mappings versus absence, imports, all source kinds, physical identity, and authored coordinates | `TestFacadeInputDestinationsAcrossSourceKinds` covers host/native/bytes/reader/mapping sources; `TestFacadeSameFragmentMountsAtSeparateCollectionDestinations` proves independent public collections can retain the same local key. `TestLayeredImportOnlyInputMaterializesDestination`, `TestLayeredHostImportsInheritInputDestinationTransitively`, `TestLayeredNativeFilesystemImportsInheritInputDestination`, and `TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity` cover inherited import placement and unchanged identity. `TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates`, `TestPlaceDocumentPreservesAuthoredEntryCoordinates`, and `TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations` distinguish fallback wrapper locations from authored coordinates. |
| Strict composition, explicit-wrapper equivalence, precedence, empty values, and domain `version` overrides | `TestLayeredDestinationStrictCompositionBoundaries`, `TestLayeredDestinationMatchesExplicitlyWrappedInput`, `TestEmptyMountedMappingRetainsLowerChildren`, and `TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics` prove existing group and overlay semantics are retained; the latter includes ordinary field-only updates rather than whole-record replacement. |
| Main-owned version checks and imported, underlay, inheritance, overlay, CLI, and ancestor protection | `TestLayeredDestinationChecksVersionOnceOnComposedMain`, `TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths`, `TestLayeredImportedVersionClaimsUseEffectiveDestination`, and `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers` cover effective paths, one main check, protected-path claims/ancestors, and nonclaiming mapping ancestors. `TestLoadVersionDestinationTypesKeepOrdinaryDecoding` preserves exact configured-path conversion and ordinary nested decoding. |
| Final-shape sibling inheritance, no cross-collection lookup, one lifecycle, and failure without a usable partial result | `TestLayeredDestinationInheritanceUsesFinalSiblingScopes`, `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, and `TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` exercise these behaviors. Reused inheritance, lifecycle, and callback tests cover generic behavior that destination placement does not alter. |
| Effective paths, presence, provenance/history, safe rendering, sensitivity, bilateral diff, and source-relative resources | `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, `TestLayeredDestinationDiffUsesBilateralSensitivity`, `TestLayeredImportedInheritedValuesRetainResourceContexts`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` connect mounted values to inspection. The imported-inheritance test specifically proves the inherited value keeps the declaring source's passive resource context. |
| Shared depth/node limits and unchanged root/absence accounting | `TestPlaceDocumentRootAndBounds`, `TestDestinationPlacementCountsAgainstSharedNodeBudget`, `TestMultipleMountedInputsSharePlacementCharges`, `TestImportedEmptyBodiesChargeEachDestinationWrapper`, `TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and `TestNoSourceAndRootPlacementAddNoWrapperCharges` cover per-document and aggregate limits without adding a new limit class. |
| Maintained consumer usage and strict peer behavior | `Example_inputDestination`, `TestCollectionBundleAssemblesMountedPeersAndResources`, and `TestCollectionPeerPromptConflictsRemainStrict` verify the simple and representative collection examples against actual values, resources, source records, and conflict behavior. |
The wider baseline-to-candidate review covered all 38 changed files (`6353598..2d8df22`): production changes are limited to the root facade fields/conversions, private input/import assembly and accounting, source record ownership, and the inheritance version-claim correction described above. The contract and tests do not impose identifier grammar, namespace uniqueness, main-only placement, source-read ordering, new budgets, nested version conversion, or expanded secret policy. The implementation wraps only acquired application bodies after processing source-root directives; placement does not change physical source identity, authored node locations, filesystem resolution, or later-tier semantics. Stage evidence above reuses existing general composition, resource, inheritance, version, lifecycle, sensitivity, and boundary tests where those behaviors are unchanged; mounted-specific cases are named separately.
Completion record: audited source candidate `2d8df22dd1cb9fde8e60853da3e2260731acf2bb`. On `go1.26.8 linux/amd64`, `./scripts/check.sh` passed, including formatting, whitespace, documentation links, tests, vet, and build. `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` also passed with dependency downloads disabled. No remaining implementation limitations were found; release preparation, CI, benchmark campaigns, and downstream upgrades remain separately scoped.
Done: one complete layered load supports input destinations with coherent values,
diagnostics, inspection, limits, and source ownership across the approved surface.
## Implementation owner map
Use graph tools to locate these symbols rather than assume line numbers:
- Public input forwarding: `layered.go`, `records.go`, `conversion.go`
(source conversions), and existing facade tests.
- Private request and lifecycle: `internal/engine/layered.go`, `load_request.go`
(`loadInput`, `acquireUnderlayGroup`, `assembleLayeredInput`), `load.go`
(`executeLoadRequest`), and `load_input.go` (`assembleBaseInputTracked`).
- Directives and imports: `directives.go` (`splitImportDirectives`,
`fragmentVersionClaim`), and `imports.go` (`visitPath`).
- Shared document semantics: `compose.go`, `document_walk.go`
(`countInputNodes`), `path.go`, `parse.go`, and `value_operation.go` limits.
- Inspection: `metadata.go`, `history.go`, `explain.go`, `resource_context.go`,
`inheritance_origin.go`, `render.go`, and `diff.go`; source copies live in
engine `records.go`. Confirm current filenames through the component overview.
## Open Questions
None required for implementation. The four policy choices are approved; this
plan selects API spelling, validation timing/error category, coordinate handling,
and accounting within them. Surface a newly discovered incompatibility or scope
change with evidence rather than resolving it by silently narrowing the feature
or strengthening an unrelated contract.

View File

@@ -1,171 +0,0 @@
# Configuration Input Destinations Roadmap
Status: implemented. This roadmap records the approved behavior and rationale
for the [PromptKit feature request](promptkit-feature-request.md). The
[implementation plan](implementation.md) records the completed sequence and
audit evidence; the [configuration contract](../config.md#input-destinations)
owns current behavior.
## Targeted end state
A consumer can assign a mapping destination to a configuration input and perform
one ordinary `LoadLayered` call. The input's application content contributes
beneath that destination while retaining normal composition, precedence,
inheritance, typed loading, diagnostics, and inspection. PromptKit can assemble
collections without parsing and rewriting sources, merging values, or rebuilding
provenance itself.
For example, a fragment defining `summarize.alerts` can contribute beneath
`collections`, `weatherreporter`, `prompts`. An independent source with the same
local key can contribute beneath another collection without conflict. Collection
and prompt names remain ordinary application keys, not ConfigKit namespaces.
The public surface adds an optional `Destination Path` field on `Input`, not a
new loading entry point or an operation on completed results. Content-bearing
main inputs and underlay inputs support
the same destination model; root placement remains the default.
The following behavior defines the target:
- Omitted and empty destinations retain root placement. Destinations consist of
literal mapping keys, including dots, slashes, and numeric-looking text.
Explicit sequence-index destinations are outside the requested capability.
- A valid destination on `NoSource` contributes nothing. An actual empty
application mapping materializes its destination mappings as explicit input,
matching authored wrappers, without clearing lower-tier children.
- Existing structural reservations apply to introduced mappings. Destinations
that materialize `configkit` or a mapping-valued `extends` contrary to existing
contracts fail through the structured loading-error boundary. Keys merely
containing those words remain ordinary keys.
- Host files, native filesystem files, bytes, readers, and mapping sources all
support placement under their existing acquisition and ownership contracts.
Filesystem context, import opt-in, and precedence remain independent choices.
- Mounted contributions have the same application values and conflict behavior
as explicitly wrapped contributions, with accurate original source locations.
Disjoint mappings combine; overlapping non-mapping values within a group
conflict even when equal. A later tier cannot repair a strict-group conflict.
- Different destinations can coexist in one group, and the same destination can
occur in different tiers. Ancestor/descendant destinations follow ordinary
composition and overlay rules without namespace uniqueness restrictions.
Later tiers recursively overlay mappings and replace other values. Empty
mappings do not clear lower values; sequences remain atomic.
- A prompt's ordinary `version` override changes that field and retains omitted
lower fields. It neither identifies a merge record nor replaces a whole prompt.
- Source-root `configkit.imports` remains structural. Imported application data
inherits the input destination transitively. Import paths still resolve from
their declaring source; containment, optionality, cycles, and repeated-source
rules remain applicable. Placement does not change physical source identity.
- Sibling inheritance runs after assembly in the effective destination scope.
Peer fragments can supply a child and its sibling parent. Qualified PromptKit
references do not acquire cross-collection inheritance meaning.
- Version ownership is enforced at effective application paths. A collection's
mounted `version` is ordinary data, while underlay/import contributions to the
actual protected path, or replacements of its ancestors, remain invalid.
There is one main-owned version check and no blanket nested numeric conversion.
- Defaults, decoding, resolution, secret lookup, and validation run once on the
assembled application, not independently on fragments. Callbacks and value
diagnostics use effective paths. Failures use structured load errors and yield
no usable partial result.
- Presence, provenance, history, explanations, effective rendering, and diffs
agree with the final values. Sensitivity is evaluated at effective application
paths under the existing historical and dependency rules.
- Existing source records expose placement, including inherited import
placement. Introduced ancestor mappings retain the contributing source's
identity; any root-location fallback identifies that document, not an authored
wrapper. Actual content retains its real source coordinates.
- Original source locations and passive resource contexts survive placement,
overrides, and inheritance. `Result.SourceContext` and
`Resolution.SourceContext` still identify the declaring filesystem and base
directory for a field such as `content_file: ./summary.md`.
- Destinations and the structure they introduce participate in existing load
limits; placement cannot bypass depth or node accounting. Source ownership,
complete-on-return loading, and detached result behavior remain intact.
Import-destination syntax, independent collection specification headers, and
cross-collection inheritance are separate possible follow-ups. This work does
not add directory discovery, collection installation, remote acquisition,
namespace ownership enforcement, dependency solving, live reload, filename-based
identity, arbitrary transformations, or new merge strategies. PromptKit owns its
collection inventory, qualified IDs, and execution semantics.
## Implementation baseline (v0.7.1)
The request reviewed v0.7.1. At that baseline, the relevant boundaries were:
- [Input](../../layered.go) holds a source, import opt-in, and filesystem context,
but no destination. Root types delegate to the engine through the existing
[facade boundary](../internal/overview.md).
- [Layered loading](../config.md#layered-loading) already provides strict groups,
ordered precedence, shared acquisition budgets, and one final lifecycle.
[Underlay acquisition](../../internal/engine/load_request.go) gathers source
applications and recomposes them into their strict group.
- [Input acquisition](../../internal/engine/load_input.go) and
[import traversal](../../internal/engine/imports.go) process source-root
directives and source identity before final assembly. Imported version claims
are checked during traversal; underlay claims are checked before group
composition. These currently operate without a destination.
- [Version claim checks](../../internal/engine/directives.go) inspect a document
using the configured application path. Wrapping only an already acquired
result would leave early fragment checks and diagnostic paths incorrect.
- [Sibling inheritance](../adr/0009-sibling-inheritance.md) already runs after
source precedence and preserves source-to-destination derivation. Placement
must feed that existing mechanism rather than resolve fragments independently.
- Existing source records identify acquisition and precedence but do not expose
input placement. Existing history retains source applications, so moving only
the final tree would be insufficient for coherent inspection.
The architectural fit is an extension of input assembly, not a parser extension
or a second configuration model. The
[architecture policy](../policy/architecture.md) supplies the ownership and
consistency constraints; [deferred scope](deferred.md) continues to govern the
unrequested features above.
## Selected Design Decisions
### Main-input destinations and absent inputs
Allow the same destination model for content-bearing main inputs and underlays,
with `VersionRule.Path` remaining an effective application path and any
specification declaration still owned by main. The checker retains its existing
handling of an absent declaration and its existing lifecycle phase. `NoSource`
contributes nothing even with a valid destination. This keeps `Input` coherent
across its roles and preserves absence without per-fragment version checks.
### Public placement inspection and synthetic ancestors
Expose effective placement as `SourceInfo.Destination Path` on existing source
records, including inherited import placement.
Use original source identity for introduced ancestor mappings, documenting that
any root-location fallback identifies the contributing document rather than a
physically authored wrapper. Actual content keeps its real coordinates. This
allows consumers to connect placement to sources without a new query system.
Preserve that relationship through inherited fields, composite origins, and the
same source selected in separate tiers. Placement is per source application;
consumers can inspect it without inferring it from final paths.
### Reserved structural keys in destinations
Use existing `Path` conventions for literal keys and parsed
numeric tokens, reject explicit index segments, and preserve current structural
reservations in the effective tree. Reject destination segments that would
materialize `configkit` or a mapping-valued `extends` contrary to those contracts,
with a clear structured destination error. Ordinary keys containing those words,
dots, or slashes remain ordinary keys. Source-root directives are processed before
placement and are not rejected simply because their application body is mounted.
Mounted and authored wrappers therefore retain the same structural meaning.
Destination diagnostics identify placement as the cause of invalid introduced
structure, using existing error categories where suitable.
### Empty mappings and presence of introduced containers
Mounting an actual empty application mapping materializes its
destination mappings as explicit contributions, matching authored wrappers.
This preserves the request's equivalence principle and lets consumers inspect
an intentionally empty collection. Such mappings still do not clear lower-tier
children. An absent source contributes nothing.
This applies equally to empty imported application bodies and inputs containing
only import directives. Presence follows the contribution's meaning rather than
whether an implementation happened to allocate wrapper nodes.

View File

@@ -1,319 +0,0 @@
# PromptKit Feature Request: Configuration Input Destinations
Status: consumer feature request; not accepted or implemented.
## Requested Capability
Provide a public API through which a consumer can select a destination mapping
path for each configuration input. The input's application content contributes
beneath that path while retaining ConfigKit's normal composition, precedence,
inheritance, validation, diagnostics, and inspection behavior.
PromptKit needs this capability for its proposal 3, **Collections With Mounted
Configuration Fragments**, recorded in PromptKit's
`docs/roadmap/configuration-collections-mounted-fragments.md`. This request
describes the public API needs and observable acceptance criteria. It does not
prescribe ConfigKit's internal design or an implementation plan.
The reviewed public baseline is ConfigKit v0.7.1. The current
[layered-loading contract](../config.md#layered-loading) lets consumers choose
sources, import policy, filesystem context, and precedence groups, but does not
provide an input destination. Existing behavior remains authoritative until an
extension is adopted.
## Consumer Problem
PromptKit applications assemble independently maintained collections of prompts,
inference profiles, and backends. Collections can contain identical local names,
such as `summarize.alerts`, without identifying the same resource. PromptKit wants
an effective configuration organized by collection, while letting authors omit
repeated collection and resource-container wrappers from individual files.
A file selected from a collection's `prompts` directory would contain:
```yaml
summarize.alerts:
version: "1"
default_profile: provider/brief
messages:
- role: user
content: Summarize the weather alerts.
output:
format: text
validation_mode: none
```
Given destination `[collections, weatherreporter, prompts]`, its contribution
would appear in the final application configuration as:
```yaml
collections:
weatherreporter:
prompts:
summarize.alerts:
version: "1"
default_profile: provider/brief
messages:
- role: user
content: Summarize the weather alerts.
output:
format: text
validation_mode: none
```
Another collection can contribute `summarize.alerts` beneath
`[collections, homewatch, prompts]` without conflicting. After final handoff,
PromptKit interprets these as `weatherreporter/summarize.alerts` and
`homewatch/summarize.alerts`. The profile reference is illustrative and must be
supplied separately by the application configuration.
PromptKit will support one installed definition per prompt. Its optional flat
`version` is ordinary domain metadata, not a merge identity. This request does
not need identified-record sequences, sequence-root documents, or a custom
merge policy.
Authors could write the complete wrappers today, but that repeats assembly
details in every fragment. Consumer-side parsing and wrapping would put generic
source processing back into PromptKit and complicate source attribution.
PromptKit instead wants to select sources and destinations, then receive one
final configuration through ConfigKit's supported loading lifecycle.
## Minimum Public API
### Per-Input Destination
Allow a destination to be attached to each `Input` passed to `LoadLayered`.
One possible public spelling is an optional `Destination Path` field on `Input`.
The name and exact signature are suggestions; equivalent public API designs
are welcome.
Illustrative consumer code using the proposed field, which does not exist in
v0.7.1:
```go
promptInput := configkit.Input{
Source: configkit.File("/usr/local/share/collections/weatherreporter/prompts/alerts.yml"),
Destination: configkit.Keys("collections", "weatherreporter", "prompts"),
}
peers := configkit.Underlay{
Inputs: []configkit.Input{promptInput},
}
```
The consumer would include `peers` in its existing `LayeredOptions` and invoke
`LoadLayered` with its normal root main input and application `Definition`.
This is a partial API sketch, not an executable example or a proposed new
loading entry point. A single public layered load is sufficient for PromptKit;
it does not require a separate mounting operation on completed results.
The API must let the consumer:
- Express a destination relative to the application configuration root using
literal mapping-key segments. Dots, slashes, and numeric-looking text inside
a key are not implicit path separators or array indexes. PromptKit's namespace
grammar remains its own domain rule.
- Omit the destination to retain existing root placement. An explicitly empty
destination should have the same meaning. Mapping destinations are sufficient;
sequence-index destinations are not needed by this request.
- Assign different destinations to inputs in the same strict composition group,
and assign the same destination to inputs in separate precedence groups.
- Use the destination with existing content-bearing source kinds: host files,
native filesystem files, bytes, readers, and mapping sources, subject to their
existing acquisition and ownership contracts.
- Retain the existing import opt-in and filesystem context independently of the
destination. A configuration destination is not a filesystem root or base
directory.
PromptKit can continue to enumerate an explicitly selected directory and attach
the same destination to its returned inputs. ConfigKit does not need a new
directory scanner, namespace registry, filename convention, or PromptKit schema.
The namespace comes from the application's assembly declaration, not from an
implicit interpretation of a directory basename.
### Representative Assembly
These are application choices, not ConfigKit-specific reserved paths:
| Selected inputs | Destination key segments |
| --- | --- |
| Application main document | Root |
| `weatherreporter/collection.yml` | `collections`, `weatherreporter` |
| YAML files under `weatherreporter/prompts` | `collections`, `weatherreporter`, `prompts` |
| YAML files under `homewatch/prompts` | `collections`, `homewatch`, `prompts` |
| YAML files under `provider/profiles` | `collections`, `provider`, `profiles` |
| YAML files under `provider/backends` | `collections`, `provider`, `backends` |
Peer collection contributions belong to a single strict underlay group when the
application wants conflict detection among them. Intentional operator overrides
use later groups or main. Destination selection must not establish precedence
or make source traversal order authorize replacement.
## Required Observable Behavior
### Composition And Precedence
Mounted and explicitly wrapped contributions must have equivalent application
values and conflict behavior at their effective destinations. Actual source
locations naturally differ and must remain accurate.
Retain the [strict composition](../config.md#strict-document-composition) and
[layering](../config.md#layered-loading) rules:
- Disjoint mapping entries at the same destination combine.
- Overlapping non-mapping values within a strict group conflict, including equal
scalars, nulls, and sequences. Different destinations do not collide merely
because the source documents share a local key.
- Later precedence layers recursively override mappings and replace other
values. A strict conflict cannot be repaired by an upper layer.
- Empty mappings do not clear lower definitions; ordinary arrays remain atomic.
In particular, `messages` does not acquire sequence-merging behavior.
- Overlapping destination paths and contributions at their ancestors follow
those same rules. No namespace-level uniqueness restriction is requested.
Changing only a prompt's `version` in a later layer changes that field while
retaining omitted lower-layer fields. ConfigKit must not treat a domain version
label as a request to replace an entire definition.
### Imports And Existing Structural Directives
An input with imports enabled must be able to use its existing source-root
`configkit.imports` directive while contributing application data at a nested
destination. The directive remains structural configuration, not an application
field below that destination.
For the minimum API, imports inherit their importing input's destination,
including transitive imports. Import paths continue to resolve relative to
their declaring source's filesystem context. A mounted document must not send
its imported application fields back to the configuration root accidentally.
Existing opt-in, containment, missing-input, cycle, and repeated-source behavior
must remain applicable. A different destination is not a request to bypass the
current rejection of a repeated physical source within a strict group.
Sibling `extends` must resolve against mappings at their effective destinations.
For example, two independently supplied profile fragments mounted beneath the
same collection's `profiles` mapping can establish `extends: brief` once they
compose. Inheritance remains ConfigKit-owned; PromptKit does not perform another
relationship-resolution pass.
### Protected Specification Paths
The distinction between source-local field names and effective application
paths is essential. PromptKit's main document will declare `version: 1` at the
configuration root. A separate collection metadata file can contain:
```yaml
version: "2.4.0"
```
Mounted at `[collections, weatherreporter]`, that field is an ordinary collection
release label. It must not be rejected as an attempt to contribute the protected
root specification field. The same applies to a prompt's nested optional
version. Only the main-owned specification declaration uses the application's
configured [VersionRule](../config.md#application-schema-versions).
Conversely, placement must not permit an underlay or imported fragment to claim
or replace the actual protected specification path or an ancestor that would
replace it. The public contract must define protection in terms of effective
application paths, rather than reserving the spelling `version` in every source.
Protection must remain valid with imports, inheritance, overlays, and explicit
CLI overrides.
PromptKit needs one main-owned specification check with the existing lifecycle.
It does not need main mounted away from the root, integer-to-string conversion
for every nested `version`, or separate compatibility checks for every fragment.
### Typed Loading, Diagnostics, And Inspection
The destination must be visible consistently through the ordinary public
loading and inspection surface:
- Final typed configuration and application callbacks use effective destination
paths. Defaults, resolution, and validation retain the existing single-load
lifecycle; fragments are not independently validated as whole applications.
- Metadata and presence queries distinguish omission, explicit null, and other
explicit values at the mounted paths.
- Diagnostics identify the effective conflicting or invalid path and the real
contributing source locations. Consumers can determine which source and
placement produced a value without recreating that relationship themselves.
- Effective rendering, explanations, history, and configuration comparison agree
with the final configuration. Existing sensitivity and disclosure rules apply
to mounted fields, including application path-based secret classification.
- Unsupported destination forms and destination-related loading failures use
the normal structured error boundary. Exact new diagnostic codes, if needed,
are for ConfigKit to define.
PromptKit does not require a separate mount registry or new inspection facade.
Existing inspection APIs are sufficient if they expose accurate effective paths,
source attribution, and placement information. ConfigKit should document how
source-local locations relate to effective paths.
### Source-Relative Resources And Lifecycle
For a mounted field such as `content_file: ./summary.md`, obtaining its effective
origin and calling `Result.SourceContext` or `Resolution.SourceContext` must
still identify the declaring source's filesystem and base directory. The same
holds when that field survives an override or is inherited into another mapping.
Its configuration destination must not change the meaning of the relative file
reference.
Retain existing source ownership, load completion, result lifetime, containment,
and resource-access behavior. Destinations must participate in documented load
limits without creating a way to bypass them; this request does not prescribe
new limit values or their accounting machinery.
## Optional Follow-Ups, Not Prerequisites
**Import destinations in YAML.** A destination on an import directive could let
publishers describe assembly in configuration rather than Go. Proposal 3
recommends destinations relative to the importing document's destination, with
omission retaining that destination. This is useful, but per-input placement
through the public API is sufficient for initial PromptKit adoption. No new
directive spelling is selected by this request.
**Independent specification headers.** A collection entry document might later
declare its own supported specification version. That needs a distinct contract
for checking the header and determining its role in the composed configuration.
Mounting collection release metadata does not supply this capability. Keep it a
separate request if PromptKit decides it is necessary.
**Cross-collection inheritance.** Nested collection profiles are not immediate
siblings across collections. Current sibling inheritance remains sufficient for
the initial proposal, with authored definitions flattened where necessary.
Generic cross-path inheritance could be considered separately if consumer needs
justify revisiting the [deferred scope](deferred.md). Mounting must not silently
reinterpret `extends: provider/brief` as a cross-collection reference.
PromptKit also does not request collection installation, automatic directory
discovery, remote fetching, namespace ownership enforcement, dependency solving,
live reload, filename-derived identity, or arbitrary document transformations.
Its qualified resource IDs, optional prompt version semantics, collection
inventory, and execution APIs remain PromptKit responsibilities.
## Public Acceptance Scenarios
Evaluate the extension through supported public loading and inspection APIs:
| Scenario | Expected consumer-visible result |
| --- | --- |
| Destination omitted or empty | Existing root-placement behavior |
| Same fragment shape mounted under two distinct collection keys from independent sources | Two separate effective resources |
| Disjoint inputs at the same destination in one group | Mapping contributions combine |
| Equal or unequal overlapping scalar fields in one group | Structured composition conflict with both source locations |
| Same destination across deliberate precedence tiers | Existing recursive overlay behavior |
| Version-only prompt override | Label changes; omitted prompt body fields survive |
| Nested mounted import without a destination directive | Imported fields contribute at the inherited destination |
| Local profile inheritance across mounted peer fragments | Parent resolves at the effective sibling scope |
| Collection metadata containing `version` mounted below root | Ordinary nested field, independent of root VersionRule |
| Mounted/imported contribution actually targeting the protected version path | Rejected under version ownership rules |
| Mounted field using source-relative content, including inheritance | Original declaring resource context remains available |
| Mounted explicit null and omitted field | Distinct effective presence information |
| Mounted validation error or secret field | Effective paths, physical locations, and existing disclosure rules agree |
| File, native filesystem, byte, reader, or mapping input with equivalent content | Equivalent application values, subject to existing source contracts |
| Unsupported destination or failed load | Normal structured failure, no usable partial result |
The minimum usable outcome is a consumer that selects collection sources and
destinations, performs one ordinary layered load, and receives final typed
configuration with reliable diagnostics and inspection. It should need no
consumer-side source rewriting, generic merging, or provenance reconstruction.

View File

@@ -1,89 +0,0 @@
# Input destination review findings
Reviewed `d3d468a..945e111` against the approved
[input-destinations roadmap](input-destinations.md),
[implementation plan](implementation.md), and
[ADR 0011](../adr/0011-input-destinations.md).
The root-owned destination fields, placement before strict composition,
transitive import placement, effective-path version ownership, final lifecycle,
source records, resource contexts, retained history, and existing disclosure
mechanisms broadly match the selected architecture. The required correction
below concerns an early diagnostic path, not loading or composition semantics.
No additional unintended strengthening of contracts was identified.
## 1. Rebase known Mapping-source failure paths to the input destination (P2)
Status: complete. Known Mapping value-builder paths are now prefixed with the
normalized destination at acquisition, preserving semantic key/index segments.
Public regressions cover mounted main and underlay failures, escaped keys,
sequence indices, cyclic values, unchanged root paths, and source-constructor
errors retaining source coordinates. The mounted value cases failed before the
fix and pass afterward. Existing directive/syntax checks remain unchanged.
Focused destination/source/diagnostic/import tests, `./scripts/check.sh`, and
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed on
`go1.26.8 linux/amd64`.
Affected: `assembleBaseInputTracked` in
[load_input.go](../../internal/engine/load_input.go), lines 27–32 at the reviewed
commit; the Mapping failure originates in `readSource` in
[source.go](../../internal/engine/source.go), lines 206–208.
`Mapping` snapshots its input through the existing value builder. When a nested
value is unsupported, cyclic, or exceeds a value-builder limit, the retained
issue already identifies a semantic path inside that application mapping.
`readSource` turns it into a structured diagnostic. The early return from
`assembleBaseInputTracked` annotates the source record with its destination but
returns those diagnostic paths unchanged.
For example, select this input as either main or an underlay:
```go
configkit.Input{
Source: configkit.Mapping("bad mapping", map[string]any{
"item": map[string]any{"bad": func() {}},
}),
Destination: configkit.Keys("catalog", "entries"),
}
```
The load correctly fails with `source.invalid`, but `Diagnostic.Path` is
`/item/bad` rather than `/catalog/entries/item/bad`. The source record correctly
reports `/catalog/entries`, so the two inspection records use inconsistent
application coordinates. A consumer displaying or associating the failure with
the final application shape would point to the wrong configuration field.
This diverges from implementation decision 10 and Stage 6: existing paths that
genuinely identify application content must be translated to effective paths
exactly once. A Mapping value-builder issue has that information; it is not a
source-read or YAML syntax failure for which a logical child path is unavailable.
Required remediation:
- Prefix known application-relative Mapping value-builder diagnostic paths with
the normalized input destination at the acquisition boundary. Preserve key
escaping and sequence-index intent in the existing semantic Path model.
- Keep source IDs, original primary/related locations, codes, value-free
messages, source destinations, and nil-result behavior unchanged. Root
placement must retain its current paths.
- Do not blanket-prefix parse/read failures, source-constructor errors, or
structural import-directive diagnostics. Do not invent logical child paths
for failures that do not identify application content.
- Add bounded regression coverage through the public API for mounted main and
underlay Mapping failures, alongside unchanged root placement. Reuse existing
syntax/directive tests to protect their different coordinate convention.
- Run the focused destination/source/diagnostic checks and the everyday check.
Reconcile the completed plan's diagnostic evidence with this correction;
do not redefine the agreed effective-path behavior to accept the gap.
## Review validation
- `GOTOOLCHAIN=local ./scripts/check.sh` passed at `945e111` on
`go1.26.8 linux/amd64` (formatting, whitespace, tests, vet, and build).
- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed.
- `GOTOOLCHAIN=local GOPROXY=off go test -count=1 ./...` passed.
- An additional external-package probe in a disposable checkout outside the
repository failed for both main and underlay placement, observing `/item/bad`
instead of `/catalog/entries/item/bad` with `source.invalid`.
- No production fixes or regression tests were added to this repository during
the review. No performance measurements or new guarantees were inferred.

View File

@@ -43,9 +43,9 @@ type CollectionProfile struct {
}
// LoadCollectionBundle demonstrates one layered collection load. Its main
// document owns the application schema version; imported collection metadata,
// prompt/profile peers, and an operator prompt-version override use ordinary
// destinations and domain fields.
// 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 {
@@ -54,7 +54,6 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
bundle := configkit.NewFilesystem("collection bundle", files)
collectionDestination := configkit.Keys("collections", "weatherreporter")
promptDestination := collectionDestination.Key("prompts")
profileDestination := collectionDestination.Key("profiles")
return configkit.LoadLayered(
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
configkit.LayeredOptions[CollectionBundleConfig]{
@@ -69,13 +68,9 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
Validate: validateCollectionBundle,
},
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{
{Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination},
{Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination},
{Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination},
{Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination},
{Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination},
}},
{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,
}}},

View File

@@ -69,22 +69,43 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
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 metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID ||
metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 {
t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation)
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)

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -1,4 +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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -155,6 +155,139 @@ func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
}
}
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")
@@ -357,6 +490,135 @@ func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
}
}
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()

View File

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

669
facade_sequence_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,33 @@ var (
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
@@ -29,6 +56,13 @@ func normalizeDestination(path Path) (Path, error) {
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,
@@ -37,27 +71,42 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
}
}
func setSourceDestinations(sources []SourceInfo, destination Path) {
for index := range sources {
sources[index].Destination = destination.clone()
}
}
// 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 !destinationWithinLimits(body, normalized, maxDepth, maxNodes) {
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{
@@ -76,28 +125,49 @@ func placeDocument(body *document.Node, destination Path, sourceRoot document.Lo
// 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() {
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
return false
}
segments := len(destination.segments)
if segments > maxDepth || segments > maxNodes/2 {
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)
wrapperNodes := destinationWrapperNodeCount(destination) + sequenceNodes
for _, segment := range destination.segments {
if segment.kind != pathSegmentKey {
return false
}
}
if documentDepth(body) > maxDepth-segments {
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)
}

View File

@@ -1,6 +1,8 @@
package engine
import (
"os"
"path/filepath"
"testing"
"testing/fstest"
@@ -66,6 +68,66 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
}
}
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()
@@ -120,6 +182,207 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
}
}
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()

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

@@ -4,6 +4,8 @@ import (
"path/filepath"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type importDestinationConfig struct {
@@ -71,6 +73,347 @@ first: one
}
}
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()
@@ -189,7 +532,7 @@ func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "shared.yml", "value: shared\n")
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml}]}\n")
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml, destination: [nested]}]}\n")
_, err := LoadLayered(
Input{Source: NoSource()},
testSecretBinding(),
@@ -205,4 +548,27 @@ func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
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

@@ -193,6 +193,43 @@ func TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers(t *testin
})
}
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

View File

@@ -12,6 +12,98 @@ type destinationLoadConfig struct {
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()

View File

@@ -2,6 +2,7 @@ package engine
import (
"errors"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
@@ -39,6 +40,57 @@ func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
}
}
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()
@@ -176,3 +228,60 @@ func TestPlaceDocumentRootAndBounds(t *testing.T) {
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

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

View File

@@ -10,10 +10,14 @@ import (
const directiveKey = "configkit"
type importDeclaration struct {
path string
glob string
optional bool
location Location
path string
glob string
destination Path
placement placementMode
placementPath Path
placementLocation *Location
optional bool
location Location
}
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
@@ -69,7 +73,7 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
if node.Kind != document.Mapping {
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
for _, entry := range node.Mapping {
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)}
}
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:
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 {
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 {
return nil
}
current := root
currentPath := Path{}
if len(rule.Path.segments) == 0 {
return current
}
for _, segment := range rule.Path.segments {
if current.Kind != document.Mapping {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return current
}
index := documentEntryIndex(current.Mapping, segment.key)
if index < 0 {
return nil
}
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
}
if segment.kind == pathSegmentIndex {
return nil
}
key := segment.key
index := documentEntryIndex(current.Mapping, key)
if index < 0 {
return nil
}
current = current.Mapping[index].Value
}
return current
}

View File

@@ -45,10 +45,12 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
// Replay reconciles with canonical decoding before domain resolution so both
// contracts agree on the effective result without retaining a tree per field.
type historyApplication struct {
layer Layer
path Path
value *document.Node
origin Origin
layer Layer
path Path
value *document.Node
origin Origin
sequences []sequenceHistoryContribution
sequencesByPath map[string]sequenceHistoryContribution
}
func cloneHistoryApplications(values []historyApplication) []historyApplication {
@@ -59,33 +61,115 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
for index, value := range values {
cloned[index] = historyApplication{
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
}
// 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) {
if !application.path.valid() || application.value == nil {
return Path{}, false
}
if application.path.String() != "" {
if pathsIntersect(application.path, query) {
if !pathContains(application.path, query) {
if pathContains(query, application.path) {
return application.path.clone(), true
}
return Path{}, false
}
if len(query.segments) == 0 {
return Path{}, true
sequencePathMatched := false
for _, contribution := range application.sequences {
container := appendDestination(application.path, contribution.path)
if !pathContains(container, query) && !pathContains(query, container) {
continue
}
sequencePathMatched = true
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
}
for length := 1; length <= len(query.segments); length++ {
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
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)
if !present {
return Path{}, false
}
if length == len(query.segments) || node.Kind != document.Mapping {
return prefix, true
if length == len(relative.segments) || node.Kind != document.Mapping {
return appendDestination(application.path, prefix), true
}
}
return Path{}, false
@@ -96,7 +180,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
return cloneOrigin(application.origin)
}
origin := Origin{Layer: application.layer}
relative, ok := relativeHistoryPath(application.path, operationPath)
relative, ok := historyApplicationLocalPath(application, operationPath)
if !ok {
return origin
}
@@ -119,13 +203,17 @@ func pathsIntersect(left, right Path) bool {
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
if application.path.String() == "" {
mergeHistoryDocument(root, application.value, index)
mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequencesByPath)
return
}
mergeHistoryAtPath(root, application.path, application.value, index)
}
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 {
return
}
@@ -141,10 +229,56 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node, index
index.append(*target, cloned)
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) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming, index)
@@ -152,7 +286,19 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
}
current := *root
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
}
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

@@ -45,17 +45,20 @@ type importAccounting struct {
}
type importTraversal struct {
access *filesystemAccess
accounting *importAccounting
limits importLimits
rule *VersionRule
destination Path
access *filesystemAccess
accounting *importAccounting
limits importLimits
rule *VersionRule
versionState *sourceVersionState
documents []*document.Node
sources []SourceInfo
active map[resourceIdentity]int
visited map[resourceIdentity]importRoute
stack []importRoute
scope acquisitionScope
itemScope bool
itemPrefix Path
itemFallback Path
sources []SourceInfo
active map[resourceIdentity]int
visited map[resourceIdentity]importRoute
stack []importRoute
}
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
@@ -63,12 +66,24 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
}
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) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
}
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, 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)
if diagnostic != nil {
@@ -83,7 +98,7 @@ func loadImportedDocumentsTracked(source Source, context *FilesystemContext, mai
if hostToken != nil {
access.token = hostToken
}
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, 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) {
@@ -91,6 +106,22 @@ func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string,
}
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)) {
_ = access.close()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
@@ -104,6 +135,7 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
}
mainInfo := cloneSourceInfo(*main.source)
mainInfo.Destination = destination.clone()
mainIsItem := main.source.placementMode == placementModeSequenceItem
if mainPath != "" {
mainInfo.Path = mainPath
}
@@ -119,11 +151,17 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
accounting.bytes += main.bytes
accounting.nodes += countInputNodes(main.document)
state := importTraversal{
access: access, accounting: accounting, limits: limits, rule: rule,
destination: destination.clone(),
documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo},
active: make(map[resourceIdentity]int), visited: visited,
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
scope: acquisitionScope{destination: destination.clone()},
sources: []SourceInfo{mainInfo},
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 {
state.visited = make(map[resourceIdentity]importRoute)
@@ -141,30 +179,118 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
state.active[mainKey] = 0
state.visited[mainKey] = 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 {
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
placementNodes := destinationWrapperNodeCount(state.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)}
if mainIsItem {
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 {
accounting.nodes += placementNodes
state.documents = append(state.documents, placed)
diagnostics = state.walkDeclarations(basePath, declarations, 0)
_, 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()
if len(diagnostics) != 0 {
if mainIsItem {
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
}
return nil, cloneSourceInfos(state.sources), diagnostics
}
if closeErr != 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 {
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.inputNodes = accounting.nodes
}
@@ -187,8 +313,9 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
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 {
childDestination := appendDestination(parentDestination, declaration.destination)
if declaration.glob != "" {
matches, diagnostics := s.expandImportGlob(basePath, declaration)
if len(diagnostics) != 0 {
@@ -205,21 +332,29 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
matched := declaration
matched.path = match
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
}
}
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 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
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 {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
}
@@ -272,7 +407,14 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
kind = SourceKindFilesystem
label = s.access.label + "/" + canonical
}
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.destination.clone(), 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 {
info.namespace = s.access.token
}
@@ -299,38 +441,339 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
}
s.accounting.nodes += nodeCount
body, declarations, diagnostics := splitImportDirectives(root)
if len(diagnostics) != 0 {
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 {
return diagnostics
}
}
placementDestination := destination
mode := placementModeMapping
if declaration.placement == placementModeSequenceItem {
mode = placementModeSequenceItem
}
if mode == placementModeSequenceItem {
route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.active[identity] = len(s.stack)
s.visited[identity] = route
s.stack = append(s.stack, route)
diagnostics := s.visitItemSource(canonical, declaration, destination, relativeDestination, depth, info, body, declarations, processing)
delete(s.active, identity)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
_, placed, err := placeDocument(body, s.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
_, 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(s.destination)
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
if versionNode := fragmentVersionClaim(placed, 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,
}}
}
route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.documents = append(s.documents, placed)
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)
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) {
remaining := s.limits.bytes - s.accounting.bytes
limit := min(int64(maxSourceBytes), remaining)

View File

@@ -9,6 +9,8 @@ import (
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
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: "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: "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},
}
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) {
t.Run("optional missing", func(t *testing.T) {
directory := t.TempDir()
@@ -486,3 +918,21 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
}
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

@@ -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) {
type entry struct {
Credential Secret `yaml:"credential"`

View File

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

View File

@@ -16,17 +16,23 @@ type Definition[T any] struct {
Validate func(*T, *Validation)
Version *VersionRule
Secret func(Path) bool
sequenceRegistrations []sequenceRegistration
}
// Options contains the application definition and per-load input selections.
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
// Overrides contains only values explicitly supplied by an application CLI.
type Options[T any] struct {
Definition Definition[T]
Imports bool
Overlay string
Filesystem *FilesystemContext
Overrides []Override
Definition Definition[T]
Imports bool
Overlay string
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode sourceVersionMode
destination Path
placementMode placementMode
sequencePolicy sequencePolicy
}
// 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) {
options, diagnostics := selectOptions(source, supplied)
policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations)
diagnostics = append(diagnostics, policyDiagnostics...)
if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
definition := options.Definition
definition.sequenceRegistrations = nil
request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem},
definition: options.Definition, overlay: options.Overlay,
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
definition: definition, sequencePolicy: policy, overlay: options.Overlay,
overrides: options.Overrides, ordinary: true,
sourceVersionMode: options.SourceVersionMode,
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
}
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) {
accumulator := diagnosticAccumulator{}
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 {
mainOptions.Overlay = request.overlay
}
input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, request.main.destination, accounting, nil, nil)
var schema *schemaDescriptor
if request.sourceVersionMode != sourceVersionModeDefault {
var schemaIssue *schemaIssue
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
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 len(diagnostics) != 0 {
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
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()
}
}
versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError()
if request.sourceVersionMode == sourceVersionModeDefault {
versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
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 {
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 {
input, diagnostics = assembleLayeredInput(request, input, accounting)
}
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
accumulator.sources = input.sources
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
@@ -208,7 +253,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
accumulator.add(problems...)
return nil, accumulator.loadError()
}
if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil {
if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil {
accumulator.add(*problem)
return nil, accumulator.loadError()
}
@@ -217,7 +262,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
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...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()

View File

@@ -8,12 +8,61 @@ import (
// assembledInput is the source/document handoff across schema and version checks.
type assembledInput struct {
parsed parsedSource
document *document.Node
composition *composedDocument
inheritance *inheritanceScope
sources []SourceInfo
contexts map[string]ResourceContext
parsed parsedSource
document *document.Node
composition *composedDocument
acquisition []acquisitionScope
inheritance *inheritanceScope
sources []SourceInfo
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) {
@@ -21,14 +70,25 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
}
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()
parseLimits := defaultParseLimits()
parseLimits.source.remaining = limits.bytes - accounting.bytes
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 {
input.sources = sourceInfos(parsed.source)
setSourceDestinations(input.sources, destination)
// 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 {
@@ -40,16 +100,38 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
}
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 {
input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken)
setSourceDestinations(input.sources, destination)
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 {
return input, diagnostics
}
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
input.document = input.composition.root
} else {
input.sources = sourceInfos(parsed.source)
setSourceDestinations(input.sources, destination)
if versionProcessing != nil {
input.document = versionProcessing.payload
}
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
return input, []Diagnostic{*diagnostic}
@@ -57,16 +139,21 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
}
}
if !options.Imports && input.document != nil {
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
return input, diagnostics
}
_, placed, err := placeDocument(input.document, destination, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
_, placed, err := placeDocumentWithMode(input.document, destination, options.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
if err != nil {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
}
localPayload := input.document
input.document = placed
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 := destinationWrapperNodeCount(destination)
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")}
}
@@ -109,15 +196,15 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
return input, nil
}
composition, diagnostics := compositionForInput(input)
composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
if len(diagnostics) != 0 {
return input, diagnostics
}
assembly := newAssemblyAccumulator(composition)
if activeOverlay(options.Overlay) {
input.sources, diagnostics = loadSelectedOverlayInto(
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
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 {
return input, diagnostics
@@ -144,17 +231,22 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
// compositionForInput initializes one handoff for overlays and CLI, including
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
return compositionForInputWithPolicy(input, sequencePolicy{})
}
func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy) (*composedDocument, []Diagnostic) {
if input.composition != nil {
return input.composition, nil
}
if input.document == 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 {
return nil, diagnostics
}
composition.inputBytes = input.parsed.bytes
composition.inputNodes = countInputNodes(input.document)
composition.acquisition = cloneAcquisitionScopes(input.acquisition)
return composition, nil
}

View File

@@ -10,17 +10,28 @@ type loadRequest[T any] struct {
main loadInput
underlays []loadGroup
definition Definition[T]
sequencePolicy sequencePolicy
sourceVersionMode sourceVersionMode
effectiveVersion *VersionRule
overlay string
overlayFilesystem *FilesystemContext
overrides []Override
ordinary bool
}
func (request loadRequest[T]) protectionVersion() *VersionRule {
if request.effectiveVersion != nil {
return request.effectiveVersion
}
return request.definition.Version
}
type loadInput struct {
source Source
imports bool
filesystem *FilesystemContext
destination Path
source Source
imports bool
filesystem *FilesystemContext
destination Path
placementMode placementMode
}
type loadGroup struct{ inputs []loadInput }
@@ -28,24 +39,22 @@ type loadGroup struct{ inputs []loadInput }
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
request.ordinary = false
var diagnostics []Diagnostic
if normalized, err := normalizeDestination(request.main.destination); err != nil {
diagnostics = append(diagnostics, invalidDestinationDiagnostic(request.main.destination))
} else {
request.main.destination = normalized
}
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem,
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
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...)
request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
for groupIndex := range request.underlays {
for inputIndex := range request.underlays[groupIndex].inputs {
input := &request.underlays[groupIndex].inputs[inputIndex]
if normalized, err := normalizeDestination(input.destination); err != nil {
diagnostics = append(diagnostics, invalidDestinationDiagnostic(input.destination))
} else {
input.destination = normalized
}
_, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem}})
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...)
}
}
@@ -68,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) {
groups := make([]*composedDocument, 0, len(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...)
mergeResourceContexts(&input.contexts, contexts)
if len(diagnostics) != 0 {
@@ -90,7 +99,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
}
if input.document != nil {
mainComposition, diagnostics := compositionForInput(input)
mainComposition, diagnostics := compositionForInputWithPolicy(input, request.sequencePolicy)
if len(diagnostics) != 0 {
return input, diagnostics
}
@@ -115,7 +124,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
selection := Source{kind: sourceBytes}
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
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 {
return input, diagnostics
}
@@ -127,7 +136,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
}
if len(request.overrides) != 0 {
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 {
return input, diagnostics
}
@@ -138,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) {
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
visited := make(map[resourceIdentity]importRoute)
hostToken := &namespaceToken{}
var documents []*document.Node
var scopes []acquisitionScope
var sources []SourceInfo
var contexts map[string]ResourceContext
for _, contribution := range group.inputs {
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}}
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, contribution.destination, accounting, visited, hostToken)
options := Options[struct{}]{
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 {
source.Layer = LayerUnderlay
source.UnderlayIndex = index
@@ -154,35 +175,47 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
}
mergeResourceContexts(&contexts, acquired.contexts)
if len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics
return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
}
if acquired.document == nil {
continue
}
if node := fragmentVersionClaim(acquired.document, rule); node != nil {
location := publicLocation(node.Location)
return nil, sources, contexts, []Diagnostic{{
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
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)
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 := composeDocuments(documents...)
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)
return nil, sources, contexts, []Diagnostic{{
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location,
}}
}
}
}
composition.inputBytes = accounting.bytes - initialBytes
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 {
origin.Layer = LayerUnderlay
composition.origins[pointer] = origin

View File

@@ -9,14 +9,15 @@ import (
// resolvedLoad carries typed phase outputs needed for validation and publication;
// it owns no source access, options, or long-lived schema state.
type resolvedLoad[T any] struct {
configuration T
decoded decodedConfiguration
metadata Metadata
final *document.Node
snapshots []resolutionSnapshot
applications []historyApplication
inheritance *inheritanceScope
contexts map[string]ResourceContext
configuration T
decoded decodedConfiguration
metadata Metadata
final *document.Node
snapshots []resolutionSnapshot
applications []historyApplication
versionEvidence []*sourceVersionEvidence
inheritance *inheritanceScope
contexts map[string]ResourceContext
}
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
@@ -57,7 +58,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
final := decoded.final
var resolutionSnapshots []resolutionSnapshot
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)
diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) {
@@ -73,7 +78,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} else if input.document != nil {
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) {
@@ -97,7 +106,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
decoded, metadata := resolved.decoded, resolved.metadata
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
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 = append(retainedTrees, decoded.final, final)
for _, application := range applications {
@@ -105,7 +115,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
}
inspection := &Inspection{
metadata: metadata,
sources: cloneSourceInfos(accumulator.sources),
sources: sources,
contexts: cloneResourceContexts(resolved.contexts),
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
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) {
t.Parallel()

View File

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

View File

@@ -9,6 +9,17 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
options = supplied[0]
}
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
if !validOverlayName(options.Overlay) {
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"))
}
}
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)
}

View File

@@ -183,15 +183,45 @@ type Location struct {
// namespace-relative filesystem path; synthetic sources have no Path.
// ImportedFrom identifies the declaration that included this source.
type SourceInfo struct {
ID string
Label string
Path string
Destination Path
Layer Layer
Kind SourceKind
UnderlayIndex int
ImportedFrom *Location
namespace *namespaceToken
ID string
Label string
Path string
Destination Path
Layer Layer
Kind SourceKind
UnderlayIndex int
ImportedFrom *Location
VersionCheck *SourceVersionCheck
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
@@ -262,9 +292,20 @@ func cloneLocations(locations []Location) []Location {
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom)
source.Destination = source.Destination.clone()
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
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 {
if sources == nil {
return nil

View File

@@ -142,6 +142,7 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
name string
main string
child string
helper string
failRead string
options Options[config]
wantErr bool
@@ -150,6 +151,16 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
{name: "acquisition", main: "value: retained\n", failRead: "main.yml", 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: "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: "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 != "" {
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}
_, err := testLoad(filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), test.options)
if (err != nil) != test.wantErr {

View File

@@ -1,6 +1,10 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
import (
"slices"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type secretDependency struct {
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 {
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)
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 {
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())
})
}
var dependencies []secretDependency
for pointer, history := range metadata.history {
addSecretPath(paths, pointer)
if path, err := ParsePath(pointer); err == nil {
addCandidate(path)
}
for _, change := range history {
paths[change.Path.String()] = change.Path.clone()
for _, dependency := range change.Dependencies {
paths[dependency.String()] = dependency.clone()
addCandidate(dependency)
dependencies = append(dependencies, secretDependency{
target: change.Path.clone(), dependency: dependency.clone(),
})
@@ -32,8 +62,10 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
if operation.control != nil {
control := operation.control.path
paths[control.String()] = control.clone()
addCandidate(control)
for _, contributor := range operation.controlOrigin.contributors {
paths[contributor.source.String()] = contributor.source.clone()
addCandidate(contributor.source)
inherited = append(inherited, secretDependency{target: control.clone(), dependency: contributor.source.clone()})
}
continue
@@ -53,6 +85,8 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
destination := appendInheritancePath(operation.transfer.to, relative)
paths[pointer] = source
paths[destination.String()] = destination.clone()
addCandidate(source)
addCandidate(destination)
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) {
pointer := path.String()
addSecretPath(paths, pointer)
if parsed, err := ParsePath(pointer); err == nil {
addCandidate(parsed)
}
if node.Sensitive {
secret[pointer] = true
}
@@ -71,17 +108,38 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
classifyProjection(tree)
}
classifyProjection(final)
for pointer, path := range paths {
classifyPath := func(path Path) {
if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true
}
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)
}
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) {
if existing, found := paths[pointer]; found && existing.valid() {
// Accumulator keys already match their paths' canonical spelling. History

View File

@@ -1,6 +1,9 @@
package engine
import "testing"
import (
"reflect"
"testing"
)
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
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)
}
}
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) {
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) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
@@ -46,7 +50,7 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
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)
if diagnostic != nil {
@@ -58,10 +62,14 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
value.Primary = nil
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) {
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
defer func() {
if !closed {
@@ -126,7 +134,15 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
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)
return sources, []Diagnostic{{
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)}
}
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
}
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
}
type versionCheckResult struct {
version Version
diagnostics []Diagnostic
}
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 nil
return versionCheckResult{}
}
version := Version{}
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)
if present {
version.Present = true
result.version.Present = true
switch node.Kind {
case document.Null:
version.Null = true
result.version.Null = true
case document.String:
version.Text = node.Text
result.version.Text = node.Text
case document.Number:
if isIntegerVersion(node) {
version.Text = node.Text
break
result.version.Text = node.Text
return result
}
fallthrough
default:
location, _ := nearestDocumentLocation(root, rule.Path)
primary := documentLocationPointer(location)
return []Diagnostic{{
result.diagnostics = []Diagnostic{{
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
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{
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
invalidPath: CodeVersionError, document: root,
@@ -229,7 +254,7 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
Message: "schema-version check failed", Primary: documentLocationPointer(location),
})
}
return canonicalDiagnostics(state.diagnostics)
return state.diagnostics
}
func isIntegerVersion(node *document.Node) bool {

View File

@@ -8,11 +8,16 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// reader ownership rules.
type Input struct {
Source Source
// Destination selects the mapping path where Source and its imported
// application contributions are placed. Its zero value keeps root placement.
// 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
Imports bool
Filesystem *FilesystemContext
// 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
Filesystem *FilesystemContext
}
// Underlay contains inputs that must compose strictly within one tier. Tiers
@@ -22,8 +27,14 @@ type Underlay struct{ Inputs []Input }
// LayeredOptions selects lower-priority tiers and final application rules.
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
// 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 {
Definition Definition[T]
SourceVersionMode SourceVersionMode
Underlays []Underlay
Overlay string
OverlayFilesystem *FilesystemContext
@@ -48,15 +59,18 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T],
}
func toEngineInput(input Input) engine.Input {
return engine.Input{
result := engine.Input{
Source: input.Source.toEngine(), Imports: input.Imports,
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] {
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),
}
if options.Underlays != nil {

View File

@@ -15,17 +15,83 @@ type Definition[T any] struct {
Validate func(*T, *Validation)
Version *VersionRule
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.
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
// 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 {
Definition Definition[T]
Imports bool
Overlay string
Filesystem *FilesystemContext
Overrides []Override
// 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
Overlay string
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode SourceVersionMode
}
// FilesystemContext supplies roots for imports and overlays. With FS nil,
@@ -53,7 +119,10 @@ type Override struct {
type Resolution struct{ value *engine.Resolution }
// 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 {
Path Path
Check func(Version, *Validation) error
@@ -226,10 +295,12 @@ func (v *Validation) engineContext() *engine.Validation {
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
result := engine.Options[T]{
Definition: toEngineDefinition(options.Definition),
Imports: options.Imports,
Overlay: options.Overlay,
Definition: toEngineDefinition(options.Definition),
Imports: options.Imports,
Overlay: options.Overlay,
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
}
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
if options.Filesystem != nil {
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] {
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 {
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
return definition.Resolve(configuration, &Resolution{value: resolution})

View File

@@ -189,13 +189,41 @@ type SourceInfo struct {
ID string
Label string
Path string
// Destination is the effective mapping path. Imported sources retain their
// input's destination; overlay and CLI sources use the root path.
Destination Path
Layer Layer
Kind SourceKind
UnderlayIndex int
ImportedFrom *Location
// 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
Kind SourceKind
UnderlayIndex int
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
@@ -266,9 +294,20 @@ func cloneLocations(locations []Location) []Location {
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.Destination = source.Destination.clone()
source.ImportedFrom = cloneLocation(source.ImportedFrom)
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
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 {
if sources == nil {
return nil

View File

@@ -1,6 +1,10 @@
package configkit
import "testing"
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestOverlayPublicValues(t *testing.T) {
if LayerOverlay != "overlay" {
@@ -23,14 +27,17 @@ func TestOverlayPublicValues(t *testing.T) {
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
destination := Keys("collections", "a/b", "0")
public := SourceInfo{ID: "source-1", Destination: destination}
public := SourceInfo{
ID: "source-1", Destination: destination,
PlacementMode: PlacementModeSequenceItem, DestinationUnresolved: true,
}
internal := toEngineSourceInfo(public)
if internal.Destination.String() != destination.String() {
t.Fatalf("engine destination = %q, want %q", internal.Destination.String(), destination.String())
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() {
t.Fatalf("public destination = %q, want %q", converted.Destination.String(), destination.String())
if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || !converted.DestinationUnresolved {
t.Fatalf("public source evidence = %#v", converted)
}
copy := cloneSourceInfo(converted)
copy.Destination = Path{}
@@ -38,3 +45,29 @@ func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
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)
}
}