34 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
55 changed files with 4550 additions and 347 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,11 @@ 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), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
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), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
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 {

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
@@ -176,12 +186,21 @@ 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 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.
`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,
@@ -397,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
@@ -744,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
@@ -884,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

@@ -21,6 +21,7 @@ sanitized shapes using only ConfigKit and the Go standard library:
| 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,
@@ -163,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

@@ -43,7 +43,7 @@ public behavior.
| 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) |
@@ -108,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
@@ -131,7 +131,24 @@ 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.
Possible follow-ups remain in [deferred scope](roadmap/deferred.md).
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

View File

@@ -9,9 +9,12 @@ Implemented component owners:
- `internal/engine` owns host and native-filesystem source acquisition,
grouped strict composition, recursive precedence assembly, per-input mapping
placement and immediate-parent-relative import placement within that
assembly, destination-local sibling inheritance before typed decoding, and
shared source-version checking with sanitized declaration evidence.
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,
@@ -48,6 +51,10 @@ The [configuration contract](../config.md) owns implemented YAML and lifecycle
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
@@ -59,3 +66,5 @@ and [release procedure](../release.md) for source-candidate gates. [ADR
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
@@ -163,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
@@ -243,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,69 +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.
## Possible source-version follow-ups
Independent collection or subtree specification policies were outside the
completed shared source-version feature and remain possible future work, with
no committed delivery plan. Current [source-version checks](../config.md#shared-source-version-checks)
use one application-owned policy across participating documents. Reconsider
independent policies if concrete consumer needs justify a separate design
decision; this is not a permanent prohibition. The original request and its
selected scope remain at `e4270c9:docs/roadmap/promptkit-feature-request.md` in
Git history. Existing sequence-composition and cross-scope inheritance deferrals
above also remain unchanged.
## Deferred performance experiments
The completed performance audit left these internal optimization candidates for
separate investigation:
- branch-local explanation replay instead of the current whole-tree replay;
- redesigning the private metadata-copy handoff while preserving detached public
records;
- caching schema descriptors across loads; and
- indexing complex contributor-trace membership while preserving its equality
rules and compact linked traces.
These are not committed implementation work. Reconsider them with evidence that
the benefit justifies the ownership, retained-memory, or equivalence risks. The
completed audit and implementation evidence are preserved in Git history at
`8e91a2b` under `docs/roadmap/`.

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

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

View File

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

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

@@ -3,14 +3,145 @@ 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

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
@@ -47,18 +74,39 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
// 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{
@@ -77,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

@@ -68,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()

View File

@@ -4,6 +4,8 @@ import (
"path/filepath"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type importDestinationConfig struct {
@@ -133,7 +135,11 @@ parent: true
}
}
composed, diagnostics := composeDocuments(state.documents...)
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)
}

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,29 @@ 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()
@@ -204,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

@@ -10,11 +10,14 @@ import (
const directiveKey = "configkit"
type importDeclaration struct {
path string
glob string
destination Path
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) {
@@ -70,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)
@@ -112,6 +115,21 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
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)}
}
@@ -145,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

@@ -51,11 +51,14 @@ type importTraversal struct {
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) {
@@ -71,16 +74,16 @@ func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemC
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processing = versionState.mainProcessing
}
return loadImportedDocumentsTrackedWithProcessing(source, context, main, destination, rule, limits, accounting, visited, hostToken, versionState, processing)
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) (*composedDocument, []SourceInfo, []Diagnostic) {
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 loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
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 {
@@ -95,7 +98,7 @@ func loadImportedDocumentsTrackedWithProcessing(source Source, context *Filesyst
if hostToken != nil {
access.token = hostToken
}
return loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
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) {
@@ -115,6 +118,10 @@ func loadImportedWithAccessTrackedWithVersion(access *filesystemAccess, basePath
}
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)}
@@ -128,6 +135,7 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa
}
mainInfo := cloneSourceInfo(*main.source)
mainInfo.Destination = destination.clone()
mainIsItem := main.source.placementMode == placementModeSequenceItem
if mainPath != "" {
mainInfo.Path = mainPath
}
@@ -144,9 +152,16 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa
accounting.nodes += countInputNodes(main.document)
state := importTraversal{
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo},
active: make(map[resourceIdentity]int), visited: visited,
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)
@@ -174,28 +189,108 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa
body, declarations, diagnostics = splitImportDirectives(main.document)
}
if len(diagnostics) == 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)}
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, destination)
_, 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
}
@@ -237,21 +332,29 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
matched := declaration
matched.path = match
matched.glob = ""
if diagnostics := s.visitPath(basePath, matched, childDestination, 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, childDestination, 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, destination Path, 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)}
}
@@ -304,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: 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
}
@@ -334,11 +444,17 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
var body *document.Node
var declarations []importDeclaration
var diagnostics []Diagnostic
var processing *sourceVersionProcessingResult
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
processed, failures := s.versionState.process(root, sourceVersionContribution, destination, sourceID)
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)
@@ -346,29 +462,35 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
return diagnostics
}
}
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
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, 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(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
protectionRule := s.rule
if s.versionState != nil && s.versionState.effectiveRule != nil {
protectionRule = s.versionState.effectiveRule
}
if versionNode := fragmentVersionClaim(placed, protectionRule); versionNode != nil {
versionLocation := publicLocation(versionNode.Location)
return []Diagnostic{{
Code: CodeImportVersion, Severity: SeverityError, Path: protectionRule.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)
@@ -378,6 +500,280 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
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,322 @@ 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()
@@ -201,6 +521,19 @@ func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
}
}
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()

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.
@@ -36,7 +37,7 @@ 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, sourceVersionMode: options.SourceVersionMode,
overlay: options.Overlay,
@@ -49,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,6 +16,8 @@ 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.
@@ -28,6 +30,9 @@ type Options[T any] struct {
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode sourceVersionMode
destination Path
placementMode placementMode
sequencePolicy sequencePolicy
}
// FilesystemContext supplies filesystem roots for imports and overlays.
@@ -137,15 +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: effectiveSourceVersionRule(options.Definition.Version, Path{}, options.SourceVersionMode),
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
}
return executeLoadRequest(request, secrets, lookup)
}
@@ -153,7 +162,8 @@ 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
@@ -197,6 +207,14 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
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, mainOptions, input)
} else {

View File

@@ -11,12 +11,60 @@ type assembledInput struct {
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) {
return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil)
}
@@ -36,6 +84,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
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 {
@@ -62,7 +111,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
if parsed.source != nil {
sourceID = parsed.source.ID
}
processed, failures := versionState.process(parsed.document, role, destination, sourceID)
processed, failures := versionState.process(parsed.document, role, destination, sourceID, options.placementMode)
if len(failures) != 0 {
return input, failures
}
@@ -72,10 +121,11 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
}
}
if options.Imports {
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing)
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)
@@ -89,13 +139,21 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
}
}
if !options.Imports && input.document != nil {
_, 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")}
}
@@ -138,7 +196,7 @@ 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
}
@@ -173,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,6 +10,7 @@ type loadRequest[T any] struct {
main loadInput
underlays []loadGroup
definition Definition[T]
sequencePolicy sequencePolicy
sourceVersionMode sourceVersionMode
effectiveVersion *VersionRule
overlay string
@@ -26,10 +27,11 @@ func (request loadRequest[T]) protectionVersion() *VersionRule {
}
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 }
@@ -37,26 +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
}
request.effectiveVersion = effectiveSourceVersionRule(request.definition.Version, request.main.destination, request.sourceVersionMode)
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem,
SourceVersionMode: request.sourceVersionMode,
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...)
}
}
@@ -79,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 := acquireUnderlayGroupWithVersion(group, index+1, request.protectionVersion(), accounting, input.versionState)
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 {
@@ -101,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
}
@@ -153,14 +151,22 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
}
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}}
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
@@ -169,35 +175,47 @@ func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRu
}
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

@@ -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"))

View File

@@ -183,16 +183,36 @@ 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
VersionCheck *SourceVersionCheck
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

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
@@ -13,18 +17,40 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
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(),
})
@@ -36,8 +62,10 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
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
@@ -57,6 +85,8 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
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})
}
}
@@ -66,6 +96,9 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
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
}
@@ -83,7 +116,7 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
secret[path.String()] = true
}
}
for _, path := range paths {
for _, path := range candidates {
classifyPath(path)
}
// Classify each original evidence shape before folding results into pointer
@@ -92,13 +125,10 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
if item == nil {
continue
}
candidate := item.resolvedPath
if !candidate.valid() {
candidate = item.path
}
classifyPath(addSecretEvidencePath(paths, item.destination, candidate))
for _, ancestor := range item.ancestors {
classifyPath(addSecretEvidencePath(paths, item.destination, ancestor.path))
for _, resolved := range item.sensitivityPaths() {
addSecretPath(paths, resolved.String())
addCandidate(resolved)
classifyPath(resolved)
}
}
return closeSecretDependencies(paths, secret, dependencies, inherited)

View File

@@ -136,7 +136,7 @@ func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *ass
}
overlayPayload := overlayRoot
if versionState != nil && versionState.mode != sourceVersionModeDefault {
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID)
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
if len(failures) != 0 {
return sources, failures
}

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

@@ -30,16 +30,17 @@ type sourceVersionAncestor struct {
}
type sourceVersionEvidence struct {
sourceID string
path Path
resolvedPath Path
destination Path
version Version
location *Location
ancestors []sourceVersionAncestor
absence sourceVersionPathAbsence
checked bool
redacted bool
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 {
@@ -80,7 +81,7 @@ func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity
redacted := evidence.redacted
if conservative {
redacted = redacted || version.Text != ""
} else if sensitivity.isSensitiveAtOrAbove(appendDestination(evidence.destination, path).String()) {
} else if sensitivity.isSensitiveAtOrAbove(evidence.sensitivityPaths()[0].String()) {
redacted = true
}
if redacted {
@@ -91,11 +92,33 @@ func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity
}
}
func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string) (sourceVersionProcessingResult, []Diagnostic) {
// 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
}
result := processSourceVersion(root, state.localRule, role, destination, state.mode)
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)
@@ -119,6 +142,10 @@ type sourceVersionProcessingResult struct {
}
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
}
@@ -126,9 +153,13 @@ func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourc
return local
}
effective := *local
prefix := destination.clone()
if placement == placementModeSequenceItem {
prefix = prefix.Index(0)
}
effective.Path = Path{
segments: append(clonePathSegments(destination.segments, len(local.Path.segments)), local.Path.segments...),
invalid: destination.invalid || local.Path.invalid,
segments: append(clonePathSegments(prefix.segments, len(local.Path.segments)), local.Path.segments...),
invalid: prefix.invalid || local.Path.invalid,
}
return &effective
}

View File

@@ -37,6 +37,40 @@ func TestSourceVersionAbsentPathKeepsUnresolvedSegmentIntent(t *testing.T) {
}
}
func TestFragmentVersionClaimUsesAssignedSequenceProjection(t *testing.T) {
t.Parallel()
fragment := sourceVersionTestDocument(t, "items: [{version: v1}]\n")
rangeForPeer := []sequenceRange{{sourceIndex: 1, path: Keys("items"), start: 1, end: 2}}
if node := fragmentVersionClaimProjected(fragment, &VersionRule{Path: Keys("items").Index(0).Key("version")}, 1, rangeForPeer); node != nil {
t.Fatalf("peer append claimed main index 0: %#v", node)
}
if node := fragmentVersionClaimProjected(fragment, &VersionRule{Path: Keys("items").Index(1).Key("version")}, 1, rangeForPeer); node == nil || node.Text != "v1" {
t.Fatalf("assigned peer index 1 claim = %#v, want its version node", node)
}
nested := sourceVersionTestDocument(t, "items: [{nested: [{version: nested-v}]}]\n")
nestedRanges := []sequenceRange{
{sourceIndex: 1, path: Keys("items"), start: 1, end: 2},
{sourceIndex: 1, path: Keys("items").Index(0).Key("nested"), start: 2, end: 3},
}
if node := fragmentVersionClaimProjected(nested, &VersionRule{Path: Keys("items").Index(1).Key("nested").Index(2).Key("version")}, 1, nestedRanges); node == nil || node.Text != "nested-v" {
t.Fatalf("nested assigned claim = %#v, want nested version", node)
}
ancestor := sourceVersionTestDocument(t, "items: {version: replacement}\n")
if node := fragmentVersionClaim(ancestor, &VersionRule{Path: Keys("items").Index(0).Key("version")}); node == nil || node.Kind != document.Mapping {
t.Fatalf("destructive sequence ancestor claim = %#v, want mapping node", node)
}
effective := effectiveSourceVersionRuleForPlacement(&VersionRule{Path: Keys("version")}, Keys("catalog", "entries"), sourceVersionModeRequired, placementModeSequenceItem)
if got, want := effective.Path.String(), "/catalog/entries/0/version"; got != want {
t.Fatalf("item effective version path = %q, want %q", got, want)
}
defaultRule := effectiveSourceVersionRuleForPlacement(&VersionRule{Path: Keys("version")}, Keys("catalog", "entries"), sourceVersionModeDefault, placementModeSequenceItem)
if got, want := defaultRule.Path.String(), "/version"; got != want {
t.Fatalf("default effective version path = %q, want %q", got, want)
}
}
type sourceVersionMountedService struct {
Version string `yaml:"version"`
}
@@ -473,6 +507,83 @@ func TestSourceVersionLoadChecksMountedMainLocallyAndProtectsEffectivePath(t *te
}
}
func TestSourceVersionItemPlacementKeepsLocalChecksAndProtectsProjectedPath(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
load func(Definition[map[string]any]) error
}{
{
name: "ordinary",
load: func(definition Definition[map[string]any]) error {
_, err := Load(Mapping("main", map[string]any{"version": "v1", "name": "ordinary"}), testSecretBinding(), Options[map[string]any]{
Definition: definition, SourceVersionMode: sourceVersionModeRequired,
destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
Overrides: []Override{{Path: Keys("catalog", "entries"), Value: []any{}, Flag: "--entries"}},
})
return err
},
},
{
name: "layered",
load: func(definition Definition[map[string]any]) error {
_, err := LoadLayered(Input{Source: Mapping("main", map[string]any{"version": "v1", "name": "layered"}), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, testSecretBinding(), LayeredOptions[map[string]any]{
Definition: definition, SourceVersionMode: sourceVersionModeRequired,
Overrides: []Override{{Path: Keys("catalog", "entries"), Value: []any{}, Flag: "--entries"}},
})
return err
},
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
checks := 0
definition := Definition[map[string]any]{Version: &VersionRule{
Path: Keys("version"),
Check: func(version Version, _ *Validation) error {
checks++
if !version.Present || version.Text != "v1" {
t.Errorf("source-local version = %#v, want v1", version)
}
return nil
},
}}
err := test.load(definition)
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("item placement override error = %T %v, want *LoadError", err, err)
}
requireSourceVersionDiagnostic(t, loadErr.Diagnostics(), CodeOverrideVersion, "/catalog/entries")
if checks != 1 {
t.Fatalf("source-version checker called %d times, want once", checks)
}
})
}
}
func TestSourceVersionItemEvidenceUsesProjectedSensitivityPath(t *testing.T) {
t.Parallel()
const canary = "PROJECTED-VERSION-EVIDENCE-CANARY"
secretPath := Keys("catalog", "entries").Index(0).Key("version")
loaded, err := Load(Mapping("main", map[string]any{"version": canary}), testSecretBinding(), Options[map[string]any]{
Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
Secret: func(path Path) bool { return path.String() == secretPath.String() },
},
SourceVersionMode: sourceVersionModeRequired,
destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
})
if err != nil {
t.Fatalf("item source-version load: %v", err)
}
sources := loaded.Inspection.Sources()
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted || sources[0].VersionCheck.Version.Text != "" {
t.Fatalf("projected source-version evidence was not redacted: %#v", sources)
}
}
func TestSourceVersionLoadChecksNullAndNoSourceMain(t *testing.T) {
t.Parallel()

View File

@@ -12,8 +12,12 @@ type Input struct {
// 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
@@ -55,10 +59,11 @@ 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] {

View File

@@ -15,6 +15,38 @@ 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
@@ -44,10 +76,17 @@ const (
// 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. Main protection remains at that path prefixed
// by the main input's Destination.
// 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]
Definition Definition[T]
// Destination places the ordinary source beneath literal mapping keys. Its
// zero value means the application root. Invalid paths fail before acquisition.
Destination Path
// PlacementMode defaults to mapping placement; unknown values and root item
// placement fail before acquisition.
PlacementMode PlacementMode
Imports bool
Overlay string
Filesystem *FilesystemContext
@@ -261,6 +300,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
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)
}
@@ -289,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,14 +189,21 @@ type SourceInfo struct {
ID string
Label string
Path string
// Destination is the effective mapping path. Imports inherit their declaring
// document's path unless they append a YAML 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

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